yit-player 0.1.0__tar.gz

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,62 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ name: Upload Python Package
5
+
6
+ on:
7
+ release:
8
+ types: [published]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ release-build:
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.x"
23
+
24
+ - name: Install build dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ python -m pip install build
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ python -m build
32
+
33
+ - name: Upload distributions
34
+ uses: actions/upload-artifact@v4
35
+ with:
36
+ name: release-dists
37
+ path: dist/
38
+
39
+ pypi-publish:
40
+ runs-on: ubuntu-latest
41
+ needs:
42
+ - release-build
43
+ permissions:
44
+ # IMPORTANT: this permission is mandatory for trusted publishing
45
+ id-token: write
46
+
47
+ # Dedicated environments with protections for publishing are strongly recommended.
48
+ environment:
49
+ name: pypi
50
+ url: https://pypi.org/p/yit-player
51
+
52
+ steps:
53
+ - name: Retrieve release distributions
54
+ uses: actions/download-artifact@v4
55
+ with:
56
+ name: release-dists
57
+ path: dist/
58
+
59
+ - name: Publish release distributions to PyPI
60
+ uses: pypa/gh-action-pypi-publish@release/v1
61
+ with:
62
+ packages-dir: dist/
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+
7
+ # Virtual Environment
8
+ venv/
9
+ env/
10
+ .env
11
+
12
+ # Yit Local Data (if run locally)
13
+ .yit/
14
+ results.json
15
+ history.json
16
+ q.txt
17
+
18
+ # OS
19
+ .DS_Store
20
+ Thumbs.db
@@ -0,0 +1,74 @@
1
+ # Yit - Agent Context & Instructions
2
+
3
+ ## Project Overview
4
+ **Yit** (Your Instant Terminal) is a headless, CLI-based music player for Windows. It uses `mpv` as the audio engine and `yt-dlp` for media resolution. The architecture separates the client (CLI) from the daemon (background mpv process).
5
+
6
+ ## Agent Interaction Protocol
7
+
8
+ ### 1. Self-Discovery
9
+ An agent should first discover what Yit can do:
10
+ ```bash
11
+ yit commands
12
+ ```
13
+ **Output (JSON):**
14
+ ```json
15
+ [
16
+ {"cmd": "search", "usage": "yit search <query> [-p]", "desc": "Search YouTube..."},
17
+ {"cmd": "play", "usage": "yit play <index>", "desc": "Play track..."},
18
+ ...
19
+ ]
20
+ ```
21
+
22
+ ### 2. State Retrieval
23
+ To get the full system state in a machine-readable format, run:
24
+ ```bash
25
+ yit agent
26
+ ```
27
+ **Output Format (JSON):**
28
+ ```json
29
+ {
30
+ "status": "playing",
31
+ "track": {
32
+ "title": "Song Title",
33
+ "url": "https://youtube.com/..."
34
+ },
35
+ "position": 125.5,
36
+ "duration": 300.0,
37
+ "volume": 100,
38
+ "loop": false,
39
+ "queue_length": 5
40
+ }
41
+ ```
42
+
43
+ ### 2. Control Commands
44
+ Use these commands to manipulate playback. They return exit code `0` on success.
45
+
46
+ | Command | Action | Notes |
47
+ | :--- | :--- | :--- |
48
+ | `yit search "<query>" -p` | Search & Auto-Play | Best for "Play X" requests. |
49
+ | `yit pause` | Pause playback | Idempotent. |
50
+ | `yit resume` | Resume playback | Idempotent. |
51
+ | `yit toggle` | Toggle play/pause | |
52
+ | `yit stop` | Kill player | Hard stop. |
53
+ | `yit next` | Skip track | |
54
+ | `yit back` | Previous track | |
55
+ | `yit loop` | Loop current | Infinite loop. |
56
+ | `yit unloop` | Disable loop | |
57
+ | `yit volume <0-100>` | Set volume | *Planned Feature* |
58
+
59
+ ### 3. File Structure
60
+ * `yit.py`: Main entry point. Handles CLI args and IPC communication.
61
+ * `yit.bat`: Windows wrapper. Ensures `venv` usage.
62
+ * `install_yit.ps1`: Self-healing installer. Creates `venv` if missing.
63
+ * `.yit/history.json`: Persistent history of played tracks.
64
+ * `.yit/results.json`: Last search results.
65
+
66
+ ### 4. IPC Mechanism
67
+ Yit communicates with `mpv` via a named pipe: `\\.\pipe\yit_socket`.
68
+ * The `send_ipc_command` function in `yit.py` handles raw JSON IPC messages.
69
+ * If extending functionality, use `input-ipc-server` commands from MPV documentation.
70
+
71
+ ## Critical Rules for Agents
72
+ 1. **Always use `yit.bat` or `yit`**: Never call `python yit.py` directly; it bypasses the venv check.
73
+ 2. **Check `yit agent` first**: Before deciding to play/pause, check the current state.
74
+ 3. **Search is blocking**: `yit search` waits for `yt-dlp`. Playback commands are non-blocking.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: yit-player
3
+ Version: 0.1.0
4
+ Summary: YouTube in Terminal - Fire-and-Forget Music CLI
5
+ Author: Yit Team
6
+ License: MIT
7
+ Requires-Python: >=3.7
8
+ Requires-Dist: yt-dlp>=2023.0.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Yit (YouTube in Terminal) Player 🎵
12
+
13
+ [![PyPI version](https://badge.fury.io/py/yit-player.svg)](https://badge.fury.io/py/yit-player)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
15
+ [![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/)
16
+
17
+ **The Fire-and-Forget Music Player for Developers.**
18
+
19
+ Yit (YouTube in Terminal) is a lightweight, headless, terminal-based audio player designed for flow states. It allows you to search, queue, and control music directly from your CLI without ever touching a browser or a heavy GUI.
20
+
21
+ It runs in the background (daemonized), meaning you can close your terminal, switch tabs, or keep coding while the music plays.
22
+
23
+ ---
24
+
25
+ ## 🚀 Features
26
+
27
+ * **Daemon Architecture**: The player runs as a detached background process. Your terminal is never blocked.
28
+ * **Instant Search**: Uses `yt-dlp` to fetch metadata in milliseconds.
29
+ * **Smart Queue**: Manage your playlist (`add`, `next`, `back`, `Loop`) with simple commands.
30
+ * **Cross-Platform**: Works natively on **Windows**, **macOS**, and **Linux**.
31
+ * **Agent-Native**: Built from the ground up to be controlled by AI Agents (Vibe Coding).
32
+
33
+ ---
34
+
35
+ ## 📦 Installation
36
+
37
+ ```bash
38
+ pip install yit-player
39
+ ```
40
+
41
+ ### Requirements
42
+ Yit uses **[mpv](https://mpv.io/)** as its audio engine.
43
+ * **Windows**: Yit will attempt to auto-install it via `winget` if missing.
44
+ * **macOS**: `brew install mpv`
45
+ * **Linux**: `sudo apt install mpv` (or your distro's equivalent).
46
+
47
+ ---
48
+
49
+ ## ⚡ Quick Start
50
+
51
+ ### 1. Search & Play
52
+ ```bash
53
+ # Search for a song
54
+ yit search "lofi hip hop"
55
+
56
+ # Auto-play the first result immediately
57
+ yit search "daft punk" -p
58
+ ```
59
+
60
+ ### 2. Control Playback
61
+ ```bash
62
+ yit pause # (or 'p')
63
+ yit resume # (or 'r')
64
+ yit toggle # Toggle play/pause
65
+ yit stop # Kill the player
66
+ ```
67
+
68
+ ### 3. Queue Management
69
+ ```bash
70
+ yit add 1 # Add result #1 from your last search to the queue
71
+ yit queue # Show the current playlist
72
+ yit next # Skip track (or 'n')
73
+ yit back # Previous track (or 'b')
74
+ yit clear # Wipe the queue
75
+ ```
76
+
77
+ ### 4. Looping
78
+ ```bash
79
+ yit loop # Loop the current track indefinitely
80
+ yit unloop # Return to normal playback
81
+ ```
82
+
83
+ ### 5. Status
84
+ ```bash
85
+ yit status # Check if currently Playing/Paused/Looped
86
+ ```
87
+ ---
88
+
89
+ ## 🤖 For AI Agents & Vibe Coding
90
+
91
+ Yit is designed to be **self-documenting** for AI context.
92
+ If you are building an AI agent or using an LLM in your IDE:
93
+
94
+ 1. **Read context**: Point your agent to [AI_INSTRUCTIONS.md](AI_INSTRUCTIONS.md) (included in the repo).
95
+ 2. **Discovery**: Run `yit commands` to get a JSON list of all capabilities.
96
+ 3. **State**: Run `yit agent` to get the full player state (Track, Time, Queue) in pure JSON.
97
+
98
+ **Example Agent Output (`yit agent`):**
99
+ ```json
100
+ {
101
+ "status": "playing",
102
+ "track": {
103
+ "title": "Never Gonna Give You Up",
104
+ "url": "https://..."
105
+ },
106
+ "position": 45.2,
107
+ "duration": 212.0,
108
+ "queue_length": 5
109
+ }
110
+ ```
111
+
112
+ ---
113
+
114
+ ## 🛠️ Architecture
115
+
116
+ * **Client**: Python CLI (`yit`) handles argument parsing and user signals.
117
+ * **Daemon**: A detached `mpv` process handles audio decoding and network streaming.
118
+ * **Communication**: IPC (Inter-Process Communication) via Named Pipes (Windows) or Unix Sockets (Linux/Mac).
119
+ * **Persistence**: `~/.yit/history.json` stores your playback history and queue metadata.
@@ -0,0 +1,109 @@
1
+ # Yit (YouTube in Terminal) Player 🎵
2
+
3
+ [![PyPI version](https://badge.fury.io/py/yit-player.svg)](https://badge.fury.io/py/yit-player)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/)
6
+
7
+ **The Fire-and-Forget Music Player for Developers.**
8
+
9
+ Yit (YouTube in Terminal) is a lightweight, headless, terminal-based audio player designed for flow states. It allows you to search, queue, and control music directly from your CLI without ever touching a browser or a heavy GUI.
10
+
11
+ It runs in the background (daemonized), meaning you can close your terminal, switch tabs, or keep coding while the music plays.
12
+
13
+ ---
14
+
15
+ ## 🚀 Features
16
+
17
+ * **Daemon Architecture**: The player runs as a detached background process. Your terminal is never blocked.
18
+ * **Instant Search**: Uses `yt-dlp` to fetch metadata in milliseconds.
19
+ * **Smart Queue**: Manage your playlist (`add`, `next`, `back`, `Loop`) with simple commands.
20
+ * **Cross-Platform**: Works natively on **Windows**, **macOS**, and **Linux**.
21
+ * **Agent-Native**: Built from the ground up to be controlled by AI Agents (Vibe Coding).
22
+
23
+ ---
24
+
25
+ ## 📦 Installation
26
+
27
+ ```bash
28
+ pip install yit-player
29
+ ```
30
+
31
+ ### Requirements
32
+ Yit uses **[mpv](https://mpv.io/)** as its audio engine.
33
+ * **Windows**: Yit will attempt to auto-install it via `winget` if missing.
34
+ * **macOS**: `brew install mpv`
35
+ * **Linux**: `sudo apt install mpv` (or your distro's equivalent).
36
+
37
+ ---
38
+
39
+ ## ⚡ Quick Start
40
+
41
+ ### 1. Search & Play
42
+ ```bash
43
+ # Search for a song
44
+ yit search "lofi hip hop"
45
+
46
+ # Auto-play the first result immediately
47
+ yit search "daft punk" -p
48
+ ```
49
+
50
+ ### 2. Control Playback
51
+ ```bash
52
+ yit pause # (or 'p')
53
+ yit resume # (or 'r')
54
+ yit toggle # Toggle play/pause
55
+ yit stop # Kill the player
56
+ ```
57
+
58
+ ### 3. Queue Management
59
+ ```bash
60
+ yit add 1 # Add result #1 from your last search to the queue
61
+ yit queue # Show the current playlist
62
+ yit next # Skip track (or 'n')
63
+ yit back # Previous track (or 'b')
64
+ yit clear # Wipe the queue
65
+ ```
66
+
67
+ ### 4. Looping
68
+ ```bash
69
+ yit loop # Loop the current track indefinitely
70
+ yit unloop # Return to normal playback
71
+ ```
72
+
73
+ ### 5. Status
74
+ ```bash
75
+ yit status # Check if currently Playing/Paused/Looped
76
+ ```
77
+ ---
78
+
79
+ ## 🤖 For AI Agents & Vibe Coding
80
+
81
+ Yit is designed to be **self-documenting** for AI context.
82
+ If you are building an AI agent or using an LLM in your IDE:
83
+
84
+ 1. **Read context**: Point your agent to [AI_INSTRUCTIONS.md](AI_INSTRUCTIONS.md) (included in the repo).
85
+ 2. **Discovery**: Run `yit commands` to get a JSON list of all capabilities.
86
+ 3. **State**: Run `yit agent` to get the full player state (Track, Time, Queue) in pure JSON.
87
+
88
+ **Example Agent Output (`yit agent`):**
89
+ ```json
90
+ {
91
+ "status": "playing",
92
+ "track": {
93
+ "title": "Never Gonna Give You Up",
94
+ "url": "https://..."
95
+ },
96
+ "position": 45.2,
97
+ "duration": 212.0,
98
+ "queue_length": 5
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## 🛠️ Architecture
105
+
106
+ * **Client**: Python CLI (`yit`) handles argument parsing and user signals.
107
+ * **Daemon**: A detached `mpv` process handles audio decoding and network streaming.
108
+ * **Communication**: IPC (Inter-Process Communication) via Named Pipes (Windows) or Unix Sockets (Linux/Mac).
109
+ * **Persistence**: `~/.yit/history.json` stores your playback history and queue metadata.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "yit-player"
7
+ version = "0.1.0"
8
+ description = "YouTube in Terminal - Fire-and-Forget Music CLI"
9
+ readme = "README.md"
10
+ authors = [{name = "Yit Team"}]
11
+ license = {text = "MIT"}
12
+ requires-python = ">=3.7"
13
+ dependencies = [
14
+ "yt-dlp>=2023.0.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ yit = "yit:main"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/yit"]
@@ -0,0 +1 @@
1
+ yt-dlp
@@ -0,0 +1,600 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+
11
+ # Constants
12
+ # Constants
13
+ YIT_DIR = Path.home() / ".yit"
14
+ RESULTS_FILE = YIT_DIR / "results.json"
15
+ HISTORY_FILE = YIT_DIR / "history.json"
16
+
17
+ if os.name == 'nt':
18
+ IPC_PIPE = r"\\.\pipe\yit_socket"
19
+ else:
20
+ IPC_PIPE = str(Path.home() / ".yit" / "socket")
21
+
22
+ def install_mpv():
23
+ """Attempts to install MPV based on OS."""
24
+ system = platform.system()
25
+ print(f"MPV not found. Attempting to install for {system}...")
26
+
27
+ try:
28
+ if system == "Windows":
29
+ print("Running: winget install mpv.mpv")
30
+ subprocess.run(["winget", "install", "mpv.mpv"], check=True)
31
+ print("Installation complete. Please restart your terminal if Yit doesn't find it immediately.")
32
+ elif system == "Darwin": # macOS
33
+ if subprocess.run(["which", "brew"], capture_output=True).returncode == 0:
34
+ print("Running: brew install mpv")
35
+ subprocess.run(["brew", "install", "mpv"], check=True)
36
+ else:
37
+ print("Homebrew not found. Please install mpv manually: brew install mpv")
38
+ elif system == "Linux":
39
+ print("Please install mpv manually (e.g., sudo apt install mpv).")
40
+ # Linux distros vary too much to auto-install safely without sudo
41
+ except Exception as e:
42
+ print(f"Installation failed: {e}")
43
+ print("Please install mpv manually.")
44
+
45
+ def check_dependencies():
46
+ """Checks if MPV is installed."""
47
+ try:
48
+ subprocess.run(["mpv", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
49
+ return True
50
+ except FileNotFoundError:
51
+ return False
52
+
53
+ def ensure_yit_dir():
54
+ if not YIT_DIR.exists():
55
+ YIT_DIR.mkdir()
56
+
57
+
58
+
59
+ def save_to_history(track):
60
+ """Saves a track to the persistent history file."""
61
+ ensure_yit_dir()
62
+ history = []
63
+ if HISTORY_FILE.exists():
64
+ try:
65
+ with open(HISTORY_FILE, "r") as f:
66
+ history = json.load(f)
67
+ except Exception:
68
+ pass
69
+
70
+ # Check for duplicates or update
71
+ existing = None
72
+ for item in history:
73
+ if item.get("url") == track["url"]:
74
+ existing = item
75
+ break
76
+
77
+ if not existing:
78
+ history.append(track)
79
+
80
+ try:
81
+ with open(HISTORY_FILE, "w") as f:
82
+ json.dump(history, f, indent=4)
83
+ except Exception as e:
84
+ print(f"Warning: Could not save history: {e}")
85
+
86
+ def send_ipc_command(command):
87
+ """Sends a JSON-formatted command to the MPV IPC pipe."""
88
+ try:
89
+ with open(IPC_PIPE, "r+b", buffering=0) as f:
90
+ payload = json.dumps(command).encode("utf-8") + b"\n"
91
+ f.write(payload)
92
+ response_line = f.readline().decode("utf-8")
93
+ if response_line:
94
+ return json.loads(response_line)
95
+ return {"error": "no_response"}
96
+ except FileNotFoundError:
97
+ print("Yit is not running.")
98
+ return None
99
+ except Exception as e:
100
+ print(f"Error communicating with player: {e}")
101
+ return None
102
+
103
+ def get_ipc_property(prop):
104
+ """Gets a property from MPV."""
105
+ try:
106
+ with open(IPC_PIPE, "r+b", buffering=0) as f:
107
+ cmd = {"command": ["get_property", prop]}
108
+ payload = json.dumps(cmd).encode("utf-8") + b"\n"
109
+ f.write(payload)
110
+
111
+ # Simple read line
112
+ response = f.readline().decode("utf-8")
113
+ return json.loads(response)
114
+ except FileNotFoundError:
115
+ return None
116
+ except Exception:
117
+ return None
118
+
119
+ def cmd_search(args):
120
+ """Searches YouTube and stores results."""
121
+ ensure_yit_dir()
122
+ query = " ".join(args.query)
123
+ print(f"Searching for '{query}'...")
124
+
125
+ # using yt-dlp via subprocess for simplicity and speed
126
+ # ytsearch5: gets 5 results
127
+ cmd = [
128
+ "yt-dlp",
129
+ "--print", "%(title)s|%(id)s",
130
+ "--flat-playlist",
131
+ f"ytsearch5:{query}"
132
+ ]
133
+
134
+ try:
135
+ # Use full path to yt-dlp if needed, assuming it's in venv or path
136
+ # In this environment, we should rely on the venv activation or use sys.executable to find it
137
+ # But for now, let's assume 'yt-dlp' is in PATH or we use the one we just installed.
138
+ # Ideally, we should use the library, but subprocess is often more stable for simple "get strings"
139
+
140
+ # Let's try to use the python library method to be cleaner if possible,
141
+ # but subprocess is standard for this tool type.
142
+
143
+ # Use system yt-dlp since we are a package now
144
+ yt_dlp_path = "yt-dlp"
145
+
146
+
147
+ command = [str(yt_dlp_path), "--print", "%(title)s||||%(webpage_url)s", "--flat-playlist", f"ytsearch5:{query}"]
148
+
149
+
150
+ result = subprocess.run(
151
+ command,
152
+ capture_output=True,
153
+ text=True,
154
+ encoding='utf-8',
155
+ errors='replace',
156
+ check=False
157
+ )
158
+
159
+ if result.returncode != 0:
160
+ print(f"Error: yt-dlp returned {result.returncode}")
161
+ print(f"Stderr: {result.stderr}")
162
+ return
163
+
164
+ if result.stdout is None:
165
+ print("Error: stdout is None")
166
+ return
167
+
168
+ lines = result.stdout.strip().split('\n')
169
+ results = []
170
+ print("\nResults:")
171
+ for i, line in enumerate(lines):
172
+ if "||||" in line:
173
+ title, url = line.split("||||", 1)
174
+ print(f"{i+1}. {title}")
175
+ results.append({"title": title, "url": url})
176
+
177
+ if not results:
178
+ print("No results found.")
179
+ return
180
+
181
+ with open(RESULTS_FILE, "w") as f:
182
+ json.dump(results, f, indent=4)
183
+
184
+ except subprocess.CalledProcessError as e:
185
+ print(f"Error searching: {e}")
186
+ except Exception as e:
187
+ print(f"Unexpected error: {e}\nTry running the setup_installer.bat")
188
+
189
+ if args.play and results:
190
+ print("\nAuto-playing result #1...")
191
+ cmd_play(SimpleNamespace(number=1))
192
+
193
+ def cmd_play(args):
194
+ """Plays the selected track number."""
195
+ if not RESULTS_FILE.exists():
196
+ print("No search results found. Run 'yit search <query>' first.")
197
+ return
198
+
199
+ try:
200
+ with open(RESULTS_FILE, "r") as f:
201
+ results = json.load(f)
202
+
203
+ idx = args.number - 1
204
+ if idx < 0 or idx >= len(results):
205
+ print("Invalid selection number.")
206
+ return
207
+
208
+ track = results[idx]
209
+ print(f"Playing: {track['title']}")
210
+ save_to_history(track)
211
+
212
+ # Check if running
213
+ is_running = send_ipc_command({"command": ["loadfile", track["url"]]})
214
+
215
+ if is_running:
216
+ print("Added to existing player.")
217
+ # Ensure it plays immediately even if previously paused
218
+ send_ipc_command({"command": ["set_property", "pause", False]})
219
+ else:
220
+ # Spawn new
221
+ cmd = [
222
+ "mpv",
223
+ "--no-video",
224
+ "--idle",
225
+ "--cache=yes",
226
+ "--prefetch-playlist=yes",
227
+ "--demuxer-max-bytes=128M",
228
+ "--demuxer-max-back-bytes=128M",
229
+ f"--input-ipc-server={IPC_PIPE}",
230
+ track["url"]
231
+ ]
232
+ # Prepare env with yt-dlp in path
233
+ env = os.environ.copy()
234
+ yt_dlp_path = Path(sys.executable).parent
235
+ env["PATH"] = str(yt_dlp_path) + os.pathsep + env["PATH"]
236
+
237
+ # Prepare subprocess args based on OS
238
+ kwargs = {
239
+ "stdout": subprocess.DEVNULL,
240
+ "stderr": subprocess.DEVNULL,
241
+ "env": env,
242
+ "close_fds": True
243
+ }
244
+
245
+ if os.name == 'nt':
246
+ kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
247
+ else:
248
+ kwargs["start_new_session"] = True
249
+
250
+ # Detach process
251
+ subprocess.Popen(cmd, **kwargs)
252
+ print("Player started in background.")
253
+
254
+ except Exception as e:
255
+ print(f"Error playing: {e}")
256
+
257
+ def cmd_pause(args):
258
+ send_ipc_command({"command": ["set_property", "pause", True]})
259
+ print("Paused.")
260
+
261
+ def cmd_resume(args):
262
+ send_ipc_command({"command": ["set_property", "pause", False]})
263
+ print("Resumed.")
264
+
265
+ def cmd_toggle(args):
266
+ send_ipc_command({"command": ["cycle", "pause"]})
267
+ print("Toggled playback.")
268
+
269
+ def cmd_stop(args):
270
+ send_ipc_command({"command": ["quit"]})
271
+ print("Stopped.")
272
+
273
+ def cmd_loop(args):
274
+ send_ipc_command({"command": ["set_property", "loop-file", "inf"]})
275
+ print("Looping current track.")
276
+
277
+ def cmd_unloop(args):
278
+ send_ipc_command({"command": ["set_property", "loop-file", "no"]})
279
+ print("Unlooped. Playback will continue normally.")
280
+
281
+ def cmd_add(args):
282
+ """Appends the selected track number to the queue."""
283
+ if not RESULTS_FILE.exists():
284
+ print("No search results found. Run 'yit search <query>' first.")
285
+ return
286
+
287
+ try:
288
+ with open(RESULTS_FILE, "r") as f:
289
+ results = json.load(f)
290
+
291
+ idx = args.number - 1
292
+ if idx < 0 or idx >= len(results):
293
+ print("Invalid selection number.")
294
+ return
295
+
296
+ track = results[idx]
297
+ print(f"Adding to queue: {track['title']}")
298
+ save_to_history(track)
299
+
300
+ # Determine if we need to spawn mpv or just append
301
+ # Try to append first
302
+ res = send_ipc_command({"command": ["loadfile", track["url"], "append-play"]})
303
+
304
+ if not res or res.get("error") != "success":
305
+ # If not running, play normally (which spawns)
306
+ print("Player not running (or append failed), starting new queue...")
307
+ cmd_play(args) # This will spawn it
308
+ else:
309
+ print("Added to queue.")
310
+
311
+ except Exception as e:
312
+ print(f"Error adding to queue: {e}")
313
+
314
+ def cmd_next(args):
315
+ send_ipc_command({"command": ["playlist-next"]})
316
+ print("Skipping to next track...")
317
+
318
+ def cmd_prev(args):
319
+ send_ipc_command({"command": ["playlist-prev"]})
320
+ print("Going to previous track...")
321
+
322
+ def cmd_restart(args):
323
+ send_ipc_command({"command": ["seek", 0, "absolute"]})
324
+ send_ipc_command({"command": ["set_property", "pause", False]})
325
+ print("Restarting current track...")
326
+
327
+ def cmd_clear(args):
328
+ send_ipc_command({"command": ["playlist-clear"]})
329
+ print("Queue cleared.")
330
+
331
+ def extract_video_id(url):
332
+ """Extracts YouTube Video ID from URL."""
333
+ if not url: return None
334
+ # Standard v= parameter
335
+ if "v=" in url:
336
+ try:
337
+ return url.split("v=")[1].split("&")[0][:11] # ID is 11 chars
338
+ except:
339
+ pass
340
+ # Shortened youtu.be/ID
341
+ if "youtu.be/" in url:
342
+ try:
343
+ return url.split("youtu.be/")[1][:11]
344
+ except:
345
+ pass
346
+ return None
347
+
348
+ def cmd_queue(args):
349
+ # Get playlist info
350
+ resp = get_ipc_property("playlist")
351
+ if not resp or resp.get("error") != "success":
352
+ print("Queue is empty (or player not running).")
353
+ return
354
+
355
+ playlist = resp.get("data", [])
356
+ if not playlist:
357
+ print("Queue is empty.")
358
+ return
359
+
360
+ # Load local results to resolve titles if missing in MPV
361
+ # Map both full URL and Video ID to title
362
+ url_map = {}
363
+ id_map = {}
364
+
365
+ # Helper to load a list of items into the maps
366
+ def load_into_maps(items):
367
+ for item in items:
368
+ url = item["url"].strip("| ")
369
+ title = item["title"]
370
+ url_map[url] = title
371
+ vid = extract_video_id(url)
372
+ if vid:
373
+ id_map[vid] = title
374
+
375
+ # 1. Load Results (Current Search)
376
+ if RESULTS_FILE.exists():
377
+ try:
378
+ with open(RESULTS_FILE, "r") as f:
379
+ load_into_maps(json.load(f))
380
+ except Exception: pass
381
+
382
+ # 2. Load History (Persistent Cache)
383
+ if HISTORY_FILE.exists():
384
+ try:
385
+ with open(HISTORY_FILE, "r") as f:
386
+ load_into_maps(json.load(f))
387
+ except Exception: pass
388
+
389
+ print("\nCurrent Queue:")
390
+ for i, item in enumerate(playlist):
391
+ prefix = "-> " if item.get("current") else " "
392
+
393
+ # Priority: MPV Title -> Local Cache (ID) -> Local Cache (URL) -> Filename -> Unknown
394
+ title = item.get("title")
395
+
396
+ if not title:
397
+ # MPV 'filename' field holds the URL
398
+ url = item.get("filename", "")
399
+
400
+ # Try exact match
401
+ if url in url_map:
402
+ title = url_map[url]
403
+ else:
404
+ # Try ID match
405
+ vid = extract_video_id(url)
406
+ if vid and vid in id_map:
407
+ title = id_map[vid]
408
+ else:
409
+ title = url or "Unknown"
410
+
411
+ print(f"{prefix}{i+1}. {title}")
412
+
413
+ def cmd_status(args):
414
+ resp = get_ipc_property("media-title")
415
+ if resp and resp.get("error") == "success" and resp.get("data"):
416
+ title = resp.get("data")
417
+
418
+ # Check pause status
419
+ paused = get_ipc_property("pause")
420
+ looping = get_ipc_property("loop-file")
421
+
422
+ status_str = "[Paused]" if paused and paused.get("data") else "[Playing]"
423
+
424
+ if looping and looping.get("data") in ["inf", "yes"]:
425
+ status_str += " [Looped]"
426
+
427
+ print(f"{status_str} {title}")
428
+ else:
429
+ # Check if running at least
430
+ if get_ipc_property("idle-active"):
431
+ print("Queue is empty.")
432
+ else:
433
+ print("Yit is not running.")
434
+
435
+ def cmd_agent(args):
436
+ """Outputs full player state as JSON for AI agents."""
437
+ state = {
438
+ "status": "stopped",
439
+ "track": {},
440
+ "position": 0,
441
+ "duration": 0,
442
+ "volume": 0,
443
+ "loop": False,
444
+ "queue_length": 0
445
+ }
446
+
447
+ # Check if running
448
+ idle_resp = get_ipc_property("idle-active")
449
+ if not idle_resp:
450
+ print(json.dumps(state, indent=2))
451
+ return
452
+
453
+ # Gather data sequentially
454
+ pause_resp = get_ipc_property("pause")
455
+ title_resp = get_ipc_property("media-title")
456
+ path_resp = get_ipc_property("path") # often URL
457
+ time_resp = get_ipc_property("time-pos")
458
+ dur_resp = get_ipc_property("duration")
459
+ vol_resp = get_ipc_property("volume")
460
+ loop_resp = get_ipc_property("loop-file")
461
+ playlist_resp = get_ipc_property("playlist-count")
462
+
463
+ # Process Status
464
+ if pause_resp and pause_resp.get("data") is True:
465
+ state["status"] = "paused"
466
+ elif pause_resp and pause_resp.get("data") is False:
467
+ state["status"] = "playing"
468
+
469
+ # Process Track Info
470
+ if title_resp and title_resp.get("data"):
471
+ state["track"]["title"] = title_resp["data"]
472
+ if path_resp and path_resp.get("data"):
473
+ state["track"]["url"] = path_resp["data"]
474
+
475
+ # Playback Info
476
+ if time_resp and time_resp.get("data"):
477
+ state["position"] = time_resp["data"]
478
+ if dur_resp and dur_resp.get("data"):
479
+ state["duration"] = dur_resp["data"]
480
+ if vol_resp and vol_resp.get("data"):
481
+ state["volume"] = vol_resp["data"]
482
+
483
+ # Loop Status
484
+ if loop_resp and loop_resp.get("data") in ["inf", "yes"]:
485
+ state["loop"] = True
486
+
487
+ # Queue Info
488
+ if playlist_resp and playlist_resp.get("data"):
489
+ state["queue_length"] = playlist_resp["data"]
490
+
491
+ print(json.dumps(state, indent=2))
492
+
493
+ def cmd_commands(args):
494
+ """Outputs available commands as JSON for AI agents."""
495
+ cmds = [
496
+ {"cmd": "search", "usage": "yit search <query> [-p]", "desc": "Search YouTube. -p to auto-play."},
497
+ {"cmd": "play", "usage": "yit play <index>", "desc": "Play a track from results."},
498
+ {"cmd": "add", "usage": "yit add <index>", "desc": "Add a track to queue."},
499
+ {"cmd": "pause", "usage": "yit pause", "desc": "Pause playback."},
500
+ {"cmd": "resume", "usage": "yit resume", "desc": "Resume playback."},
501
+ {"cmd": "stop", "usage": "yit stop", "desc": "Stop playback completely."},
502
+ {"cmd": "next", "usage": "yit next", "desc": "Skip to next track."},
503
+ {"cmd": "back", "usage": "yit back", "desc": "Go to previous track."},
504
+ {"cmd": "loop", "usage": "yit loop", "desc": "Loop current track indefinitely."},
505
+ {"cmd": "unloop", "usage": "yit unloop", "desc": "Stop looping."},
506
+ {"cmd": "queue", "usage": "yit queue", "desc": "Show current queue."},
507
+ {"cmd": "clear", "usage": "yit clear", "desc": "Clear the queue."},
508
+ {"cmd": "status", "usage": "yit status", "desc": "Show playback status (text)."},
509
+ {"cmd": "agent", "usage": "yit agent", "desc": "Get full system state (JSON)."},
510
+ {"cmd": "commands", "usage": "yit commands", "desc": "Get this list (JSON)."},
511
+ {"cmd": "0", "usage": "yit 0", "desc": "Replay current track."}
512
+ ]
513
+ print(json.dumps(cmds, indent=2))
514
+
515
+ def main():
516
+ if not check_dependencies():
517
+ print("MPV is required but not found in PATH.")
518
+ print("Yit can attempt to install it for you.")
519
+ response = input("Install MPV now? (Y/n): ").strip().lower()
520
+ if response in ["", "y", "yes"]:
521
+ install_mpv()
522
+ if not check_dependencies():
523
+ print("Installation completed but 'mpv' is not yet in PATH.")
524
+ print("Please restart your terminal/shell and try again.")
525
+ sys.exit(1)
526
+ else:
527
+ print("MPV is required for Yit. Exiting.")
528
+ sys.exit(1)
529
+
530
+ parser = argparse.ArgumentParser(description="Yit (YouTube in Terminal) - Fire-and-Forget Music Player")
531
+ subparsers = parser.add_subparsers(dest="command", required=True)
532
+
533
+ # Search
534
+ parser_search = subparsers.add_parser("search", help="Search YouTube")
535
+ parser_search.add_argument("query", nargs="+", help="Search query")
536
+ parser_search.add_argument("-p", "--play", action="store_true", help="Auto-play the first result")
537
+ parser_search.set_defaults(func=cmd_search)
538
+
539
+ # Play
540
+ parser_play = subparsers.add_parser("play", help="Play a song by number")
541
+ parser_play.add_argument("number", type=int, help="Track number from search results")
542
+ parser_play.set_defaults(func=cmd_play)
543
+
544
+ # Controls
545
+ parser_pause = subparsers.add_parser("pause", aliases=["p"], help="Pause playback")
546
+ parser_pause.set_defaults(func=cmd_pause)
547
+
548
+ parser_resume = subparsers.add_parser("resume", aliases=["r"], help="Resume playback")
549
+ parser_resume.set_defaults(func=cmd_resume)
550
+
551
+ parser_toggle = subparsers.add_parser("toggle", help="Toggle pause/resume")
552
+ parser_toggle.set_defaults(func=cmd_toggle)
553
+
554
+ parser_stop = subparsers.add_parser("stop", help="Stop playback")
555
+ parser_stop.set_defaults(func=cmd_stop)
556
+
557
+ # Add to queue
558
+ parser_add = subparsers.add_parser("add", help="Add song to queue")
559
+ parser_add.add_argument("number", type=int, help="Track number")
560
+ parser_add.set_defaults(func=cmd_add)
561
+
562
+ # Queue management
563
+ parser_queue = subparsers.add_parser("queue", help="Show queue")
564
+ parser_queue.set_defaults(func=cmd_queue)
565
+
566
+ parser_clear = subparsers.add_parser("clear", help="Clear queue")
567
+ parser_clear.set_defaults(func=cmd_clear)
568
+
569
+ # Fast Navigation (Safe Aliases)
570
+ # Next
571
+ parser_next = subparsers.add_parser("next", aliases=["n"], help="Next track")
572
+ parser_next.set_defaults(func=cmd_next)
573
+
574
+ # Previous (Back)
575
+ parser_prev = subparsers.add_parser("back", aliases=["b"], help="Previous track")
576
+ parser_prev.set_defaults(func=cmd_prev)
577
+
578
+ # Replay/Restart
579
+ parser_restart = subparsers.add_parser("replay", aliases=["0"], help="Replay current track")
580
+ parser_restart.set_defaults(func=cmd_restart)
581
+
582
+ parser_status = subparsers.add_parser("status", help="Show status")
583
+ parser_status.set_defaults(func=cmd_status)
584
+
585
+ # Agent Interface
586
+ parser_agent = subparsers.add_parser("agent", help="JSON output for AI agents")
587
+ parser_agent.set_defaults(func=cmd_agent)
588
+
589
+ parser_cmds = subparsers.add_parser("commands", help="JSON command list for AI agents")
590
+ parser_cmds.set_defaults(func=cmd_commands)
591
+
592
+ # Loop
593
+ subparsers.add_parser("loop", help="Loop current track").set_defaults(func=cmd_loop)
594
+ subparsers.add_parser("unloop", help="Stop looping").set_defaults(func=cmd_unloop)
595
+
596
+ args = parser.parse_args()
597
+ args.func(args)
598
+
599
+ if __name__ == "__main__":
600
+ main()