mytunes-pro 2.0.0__tar.gz → 2.0.2__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.
- {mytunes_pro-2.0.0/src/mytunes_pro.egg-info → mytunes_pro-2.0.2}/PKG-INFO +22 -6
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/README.md +21 -5
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/pyproject.toml +1 -1
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes/app.py +287 -224
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2/src/mytunes_pro.egg-info}/PKG-INFO +22 -6
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/LICENSE +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/setup.cfg +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes/__init__.py +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes_pro.egg-info/SOURCES.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes_pro.egg-info/dependency_links.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes_pro.egg-info/entry_points.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes_pro.egg-info/requires.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.2}/src/mytunes_pro.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mytunes-pro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
5
5
|
Author-email: loxo <loxo5432@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/postgresql-co-kr/mytunes
|
|
@@ -19,7 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
# 🎵 MyTunes Pro (Korean)
|
|
21
21
|
|
|
22
|
-
## 🚀 Professional TUI Music Player v2.0.
|
|
22
|
+
## 🚀 Professional TUI Music Player v2.0.2
|
|
23
23
|
|
|
24
24
|
MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
|
|
25
25
|
Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
|
|
@@ -184,19 +184,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
184
184
|
| **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
|
|
185
185
|
| **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
|
|
186
186
|
| **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
|
|
187
|
-
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `
|
|
187
|
+
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
|
|
188
|
+
| **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
|
|
188
189
|
| **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
|
|
189
190
|
|
|
190
191
|
### 🧭 기본 탐색
|
|
191
192
|
| 키 | 동작 |
|
|
192
193
|
| :--- | :--- |
|
|
193
194
|
| `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
|
|
194
|
-
| `Enter` / `l` | **선택 / 재생**
|
|
195
|
+
| `Enter` / `l` | **선택 / 재생** |
|
|
195
196
|
| `Space` | 재생 / 일시정지 (Play/Pause) |
|
|
196
197
|
| `-` / `+` | **볼륨 조절** (- / +) |
|
|
197
198
|
| `,` / `.` | 10초 뒤로 / 앞으로 감기 |
|
|
198
199
|
| `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
|
|
199
200
|
| `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
|
|
201
|
+
| `L` | **앞으로 가기** |
|
|
200
202
|
| `/` | **검색** (Vim Style) |
|
|
201
203
|
|
|
202
204
|
---
|
|
@@ -210,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
210
212
|
|
|
211
213
|
# 🎵 MyTunes Pro (English)
|
|
212
214
|
|
|
213
|
-
**Modern CLI YouTube Music Player (
|
|
215
|
+
**Modern CLI YouTube Music Player (v2.0.2)**
|
|
214
216
|
A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
215
217
|
|
|
216
218
|
---
|
|
@@ -291,13 +293,27 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
|
|
|
291
293
|
| **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
|
|
292
294
|
| **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
|
|
293
295
|
| **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
|
|
294
|
-
| **`6`** | **Back** | Go back (Same as `Q`, `
|
|
296
|
+
| **`6`** | **Back** | Go back (Same as `Q`, `h`) |
|
|
297
|
+
| **`L`** | **Forward** | Go forward (`Right Arrow`) |
|
|
295
298
|
| **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
|
|
296
299
|
|
|
297
300
|
---
|
|
298
301
|
|
|
299
302
|
## 🔄 Changelog
|
|
300
303
|
|
|
304
|
+
### v2.0.2 (Stability & Browser Optimization)
|
|
305
|
+
|
|
306
|
+
- **Browser Launch**: Switched to fully decoupled `subprocess.Popen` logic for browser opening. This eliminates occasional TUI freezes when launching YouTube (F7) or Live Station (F8) by bypassing `webbrowser` library limitations.
|
|
307
|
+
- **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
|
|
308
|
+
- **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
|
|
309
|
+
|
|
310
|
+
### v2.0.1 (Keymap Refinement & Version Sync)
|
|
311
|
+
|
|
312
|
+
- **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
|
|
313
|
+
- **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
|
|
314
|
+
- **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
|
|
315
|
+
- **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
|
|
316
|
+
|
|
301
317
|
### v1.9.9 (Domain Migration & Realtime Sync)
|
|
302
318
|
|
|
303
319
|
- **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🎵 MyTunes Pro (Korean)
|
|
2
2
|
|
|
3
|
-
## 🚀 Professional TUI Music Player v2.0.
|
|
3
|
+
## 🚀 Professional TUI Music Player v2.0.2
|
|
4
4
|
|
|
5
5
|
MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
|
|
6
6
|
Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
|
|
@@ -165,19 +165,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
165
165
|
| **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
|
|
166
166
|
| **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
|
|
167
167
|
| **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
|
|
168
|
-
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `
|
|
168
|
+
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
|
|
169
|
+
| **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
|
|
169
170
|
| **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
|
|
170
171
|
|
|
171
172
|
### 🧭 기본 탐색
|
|
172
173
|
| 키 | 동작 |
|
|
173
174
|
| :--- | :--- |
|
|
174
175
|
| `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
|
|
175
|
-
| `Enter` / `l` | **선택 / 재생**
|
|
176
|
+
| `Enter` / `l` | **선택 / 재생** |
|
|
176
177
|
| `Space` | 재생 / 일시정지 (Play/Pause) |
|
|
177
178
|
| `-` / `+` | **볼륨 조절** (- / +) |
|
|
178
179
|
| `,` / `.` | 10초 뒤로 / 앞으로 감기 |
|
|
179
180
|
| `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
|
|
180
181
|
| `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
|
|
182
|
+
| `L` | **앞으로 가기** |
|
|
181
183
|
| `/` | **검색** (Vim Style) |
|
|
182
184
|
|
|
183
185
|
---
|
|
@@ -191,7 +193,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
191
193
|
|
|
192
194
|
# 🎵 MyTunes Pro (English)
|
|
193
195
|
|
|
194
|
-
**Modern CLI YouTube Music Player (
|
|
196
|
+
**Modern CLI YouTube Music Player (v2.0.2)**
|
|
195
197
|
A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
196
198
|
|
|
197
199
|
---
|
|
@@ -272,13 +274,27 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
|
|
|
272
274
|
| **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
|
|
273
275
|
| **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
|
|
274
276
|
| **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
|
|
275
|
-
| **`6`** | **Back** | Go back (Same as `Q`, `
|
|
277
|
+
| **`6`** | **Back** | Go back (Same as `Q`, `h`) |
|
|
278
|
+
| **`L`** | **Forward** | Go forward (`Right Arrow`) |
|
|
276
279
|
| **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
|
|
277
280
|
|
|
278
281
|
---
|
|
279
282
|
|
|
280
283
|
## 🔄 Changelog
|
|
281
284
|
|
|
285
|
+
### v2.0.2 (Stability & Browser Optimization)
|
|
286
|
+
|
|
287
|
+
- **Browser Launch**: Switched to fully decoupled `subprocess.Popen` logic for browser opening. This eliminates occasional TUI freezes when launching YouTube (F7) or Live Station (F8) by bypassing `webbrowser` library limitations.
|
|
288
|
+
- **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
|
|
289
|
+
- **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
|
|
290
|
+
|
|
291
|
+
### v2.0.1 (Keymap Refinement & Version Sync)
|
|
292
|
+
|
|
293
|
+
- **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
|
|
294
|
+
- **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
|
|
295
|
+
- **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
|
|
296
|
+
- **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
|
|
297
|
+
|
|
282
298
|
### v1.9.9 (Domain Migration & Realtime Sync)
|
|
283
299
|
|
|
284
300
|
- **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
|
|
@@ -18,12 +18,8 @@ import locale
|
|
|
18
18
|
import signal
|
|
19
19
|
import warnings
|
|
20
20
|
import webbrowser
|
|
21
|
-
# Suppress urllib3 warning about LibreSSL compatibility
|
|
22
|
-
warnings.filterwarnings("ignore", message=".*urllib3 v2 only supports OpenSSL 1.1.1+.*")
|
|
23
|
-
import webbrowser
|
|
24
21
|
import tempfile
|
|
25
22
|
import shutil
|
|
26
|
-
|
|
27
23
|
import requests
|
|
28
24
|
|
|
29
25
|
|
|
@@ -36,7 +32,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
|
|
|
36
32
|
LOG_FILE = "/tmp/mytunes_mpv.log"
|
|
37
33
|
PID_FILE = "/tmp/mytunes_mpv.pid"
|
|
38
34
|
APP_NAME = "MyTunes Pro"
|
|
39
|
-
APP_VERSION = "2.0.
|
|
35
|
+
APP_VERSION = "2.0.2"
|
|
40
36
|
|
|
41
37
|
# === [Strings & Localization] ===
|
|
42
38
|
STRINGS = {
|
|
@@ -56,7 +52,7 @@ STRINGS = {
|
|
|
56
52
|
"fav_added": "★ 즐겨찾기에 추가됨",
|
|
57
53
|
"fav_removed": "☆ 즐겨찾기 해제됨",
|
|
58
54
|
"header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
|
|
59
|
-
"header_r2": "[F7]유튜브 [F8]라이브 [F9]라이브공유 [SPC]Play/Stop [+/-]볼륨 [<>]빨리감기",
|
|
55
|
+
"header_r2": "[F7]유튜브 [F8]라이브 [F9]라이브공유 [SPC]Play/Stop [+/-]볼륨 [<>]빨리감기 [D/Del]삭제",
|
|
60
56
|
"help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [F7]유튜브 [F8]라이브 [F9]라이브공유",
|
|
61
57
|
"menu_main": "☰ 메인 메뉴",
|
|
62
58
|
"menu_search_results": "⌕ YouTube 음악 검색",
|
|
@@ -85,7 +81,7 @@ STRINGS = {
|
|
|
85
81
|
"fav_added": "★ Added to Favorites",
|
|
86
82
|
"fav_removed": "☆ Removed from Favorites",
|
|
87
83
|
"header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
|
|
88
|
-
"header_r2": "[F7]YT [F8]Live [F9]LiveShare [SPC]Play/Stop [+/-]Vol [<>]Seek",
|
|
84
|
+
"header_r2": "[F7]YT [F8]Live [F9]LiveShare [SPC]Play/Stop [+/-]Vol [<>]Seek [D/Del]Del",
|
|
89
85
|
"help_guide": "[j/k]Move [En]Select [h/q]Back [S/1]Srch [F/2]Fav [R/3]Hist [M/4]Main [F7]YT [F8]Live [F9]Share",
|
|
90
86
|
"menu_main": "☰ Main Menu",
|
|
91
87
|
"menu_search_results": "⌕ Search YouTube Music",
|
|
@@ -100,11 +96,11 @@ STRINGS = {
|
|
|
100
96
|
}
|
|
101
97
|
}
|
|
102
98
|
|
|
103
|
-
# === [Data Management] ===
|
|
104
99
|
class DataManager:
|
|
105
100
|
def __init__(self):
|
|
106
101
|
self.data = self.load_data()
|
|
107
102
|
self.favorites_set = {f['url'] for f in self.data.get('favorites', []) if 'url' in f}
|
|
103
|
+
self.lock = threading.Lock()
|
|
108
104
|
|
|
109
105
|
# Auto-fetch country if missing
|
|
110
106
|
if 'country' not in self.data:
|
|
@@ -124,8 +120,11 @@ class DataManager:
|
|
|
124
120
|
return {"history": [], "favorites": [], "language": "ko", "resume": {}, "search_results_history": []}
|
|
125
121
|
|
|
126
122
|
def save_data(self):
|
|
127
|
-
with
|
|
128
|
-
|
|
123
|
+
with self.lock:
|
|
124
|
+
try:
|
|
125
|
+
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
|
126
|
+
json.dump(self.data, f, indent=2, ensure_ascii=False)
|
|
127
|
+
except Exception: pass
|
|
129
128
|
|
|
130
129
|
def get_progress(self, url):
|
|
131
130
|
return self.data.get("resume", {}).get(url, 0)
|
|
@@ -230,16 +229,35 @@ class DataManager:
|
|
|
230
229
|
self.data['search_results_history'] = unique_history[:200]
|
|
231
230
|
self.save_data()
|
|
232
231
|
|
|
232
|
+
def remove_favorite_by_index(self, index):
|
|
233
|
+
if 0 <= index < len(self.data['favorites']):
|
|
234
|
+
item = self.data['favorites'].pop(index)
|
|
235
|
+
if item.get('url') in self.favorites_set:
|
|
236
|
+
self.favorites_set.remove(item['url'])
|
|
237
|
+
self.save_data()
|
|
238
|
+
return True
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
def remove_history_by_index(self, index):
|
|
242
|
+
if 0 <= index < len(self.data['history']):
|
|
243
|
+
self.data['history'].pop(index)
|
|
244
|
+
self.save_data()
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def remove_search_history_by_index(self, index):
|
|
249
|
+
if 0 <= index < len(self.data['search_results_history']):
|
|
250
|
+
self.data['search_results_history'].pop(index)
|
|
251
|
+
self.save_data()
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
233
256
|
# === [Player Logic with Advanced IPC] ===
|
|
234
257
|
class Player:
|
|
235
258
|
def __init__(self):
|
|
236
259
|
self.current_proc = None
|
|
237
260
|
self.loading = False
|
|
238
|
-
|
|
239
|
-
self.current_proc = None
|
|
240
|
-
self.loading = False
|
|
241
|
-
|
|
242
|
-
|
|
243
261
|
self.loading_ts = 0
|
|
244
262
|
|
|
245
263
|
# Cleanup pre-existing instance if any
|
|
@@ -352,7 +370,7 @@ class Player:
|
|
|
352
370
|
"""Send raw command list to MPV via JSON IPC."""
|
|
353
371
|
try:
|
|
354
372
|
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
355
|
-
client.settimeout(0.
|
|
373
|
+
client.settimeout(0.5) # Fast timeout (Optimization for Sleep/Wake resilience)
|
|
356
374
|
client.connect(MPV_SOCKET)
|
|
357
375
|
cmd_str = json.dumps({"command": command}) + "\n"
|
|
358
376
|
client.send(cmd_str.encode('utf-8'))
|
|
@@ -410,10 +428,11 @@ class MyTunesApp:
|
|
|
410
428
|
|
|
411
429
|
# Search State
|
|
412
430
|
self.current_search_query = None
|
|
413
|
-
self.search_page = 1
|
|
414
|
-
self.is_loading_more = False
|
|
431
|
+
# self.search_page = 1 # Deprecated: Pagination Removed v2.0.2
|
|
432
|
+
# self.is_loading_more = False # Deprecated
|
|
415
433
|
|
|
416
434
|
# Playback State
|
|
435
|
+
|
|
417
436
|
self.playback_time = 0
|
|
418
437
|
self.playback_duration = 0
|
|
419
438
|
self.is_paused = False
|
|
@@ -436,6 +455,8 @@ class MyTunesApp:
|
|
|
436
455
|
curses.curs_set(0)
|
|
437
456
|
self.stdscr.nodelay(True)
|
|
438
457
|
self.stdscr.timeout(200) # Update loop every 200ms
|
|
458
|
+
self.last_input_time = time.time() # For Idle Detection
|
|
459
|
+
|
|
439
460
|
|
|
440
461
|
# Register Signal for Terminal Disconnect (Window Close)
|
|
441
462
|
try:
|
|
@@ -523,6 +544,15 @@ class MyTunesApp:
|
|
|
523
544
|
elif self.playback_time > 10:
|
|
524
545
|
self.dm.set_progress(self.current_track['url'], self.playback_time)
|
|
525
546
|
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# Safety: If loading takes too long (> 8s), force reset to allow error handling/skip
|
|
550
|
+
# Consolidated redundancy checks into a single clean block
|
|
551
|
+
now = time.time()
|
|
552
|
+
if self.player.loading and (now - self.player.loading_ts > 8):
|
|
553
|
+
self.player.loading = False
|
|
554
|
+
self.status_msg = "⚠️ Load timed out. Skipping..."
|
|
555
|
+
|
|
526
556
|
# 2. Frequent: Pause state (Every 2 loops ~400ms)
|
|
527
557
|
if self.loop_count % 2 == 0:
|
|
528
558
|
p = self.player.get_property("pause")
|
|
@@ -542,10 +572,6 @@ class MyTunesApp:
|
|
|
542
572
|
is_idle = self.player.get_property("idle-active")
|
|
543
573
|
if is_idle and self.player.loading:
|
|
544
574
|
self.player.loading = False
|
|
545
|
-
|
|
546
|
-
# Timeout fallback for loading state (remains every loop logic)
|
|
547
|
-
if self.player.loading and (time.time() - getattr(self.player, 'loading_ts', 0) > 8):
|
|
548
|
-
self.player.loading = False
|
|
549
575
|
|
|
550
576
|
# Periodic Save (Throttle 10s)
|
|
551
577
|
if time.time() - getattr(self, 'last_save_time', 0) > 10:
|
|
@@ -570,6 +596,10 @@ class MyTunesApp:
|
|
|
570
596
|
|
|
571
597
|
if key == -1: return
|
|
572
598
|
|
|
599
|
+
# Reset Idle Timer
|
|
600
|
+
self.last_input_time = time.time()
|
|
601
|
+
|
|
602
|
+
|
|
573
603
|
# Handle formatting: invalid key might be int -1
|
|
574
604
|
|
|
575
605
|
# Resize Info
|
|
@@ -580,10 +610,37 @@ class MyTunesApp:
|
|
|
580
610
|
|
|
581
611
|
# GLOBAL ESC: Background Play (Exit but keep music)
|
|
582
612
|
# get_wch returns int 27 or str '\x1b' depending on system/lib
|
|
613
|
+
# v2.0.1 MAC FIX: Check for Option+Backspace (ESC then 127) for Deletion
|
|
583
614
|
if key == 27 or key == '\x1b':
|
|
584
|
-
|
|
585
|
-
self.
|
|
586
|
-
|
|
615
|
+
# Peek for next key with very short timeout
|
|
616
|
+
self.stdscr.timeout(50)
|
|
617
|
+
try:
|
|
618
|
+
next_key = self.stdscr.getch()
|
|
619
|
+
if next_key == 127: # Backspace
|
|
620
|
+
# This is Option+Backspace -> Treat as DELETE
|
|
621
|
+
key = curses.KEY_DC # Transform to Delete Key
|
|
622
|
+
else:
|
|
623
|
+
# If valid key but not 127, put it back or handle?
|
|
624
|
+
# For simplicity, if it's not the sequence we want, we treat ESC as ESC
|
|
625
|
+
# and if we consumed a key, well, generic ESC logic applies.
|
|
626
|
+
# Ideally ungetch if possible, but for now fallback to ESC behavior.
|
|
627
|
+
# But if we consumed a legitimate key user typed fast, that's bad.
|
|
628
|
+
# However, 50ms is very fast.
|
|
629
|
+
if next_key != -1:
|
|
630
|
+
curses.ungetch(next_key)
|
|
631
|
+
|
|
632
|
+
# Proceed with standard ESC behavior
|
|
633
|
+
self.stop_on_exit = False
|
|
634
|
+
self.running = False
|
|
635
|
+
return
|
|
636
|
+
except:
|
|
637
|
+
# Timeout / Error -> Just ESC
|
|
638
|
+
self.stop_on_exit = False
|
|
639
|
+
self.running = False
|
|
640
|
+
return
|
|
641
|
+
finally:
|
|
642
|
+
# Restore timeout
|
|
643
|
+
self.stdscr.timeout(1000 if (time.time() - getattr(self, 'last_input_time', 0) > 60 and self.is_paused) else 200)
|
|
587
644
|
|
|
588
645
|
# Handle Mouse Click
|
|
589
646
|
if key == curses.KEY_MOUSE:
|
|
@@ -603,12 +660,10 @@ class MyTunesApp:
|
|
|
603
660
|
rel_x = mx - branding_x
|
|
604
661
|
if rel_x < 15:
|
|
605
662
|
url = "https://mytunes-pro.com"
|
|
606
|
-
self.
|
|
607
|
-
threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
|
|
663
|
+
self.open_browser(url)
|
|
608
664
|
elif rel_x > 15:
|
|
609
665
|
url = "https://postgresql.co.kr"
|
|
610
|
-
self.
|
|
611
|
-
threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
|
|
666
|
+
self.open_browser(url)
|
|
612
667
|
except:
|
|
613
668
|
pass
|
|
614
669
|
return
|
|
@@ -619,9 +674,10 @@ class MyTunesApp:
|
|
|
619
674
|
current_list = self.get_current_list()
|
|
620
675
|
|
|
621
676
|
# Navigation logic
|
|
622
|
-
# Back: Q, Left Arrow, Backspace,
|
|
677
|
+
# Back: Q, Left Arrow, Backspace, h, 6
|
|
678
|
+
# Fix: Removed Korean mappings ('ㅂ', 'ㅗ') to prevent IME ghost keys per user request
|
|
623
679
|
if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
|
|
624
|
-
k_char in ['q', '
|
|
680
|
+
k_char in ['q', '6', 'h']:
|
|
625
681
|
if len(self.view_stack) > 1:
|
|
626
682
|
# Pop current view and push to forward stack
|
|
627
683
|
current_view = self.view_stack.pop()
|
|
@@ -634,7 +690,8 @@ class MyTunesApp:
|
|
|
634
690
|
|
|
635
691
|
# Forward: L, Right Arrow (Browser Style)
|
|
636
692
|
# Re-visit the view we just popped from
|
|
637
|
-
|
|
693
|
+
# Fix: Removed Korean mappings ('ㅣ') to prevent IME ghost keys
|
|
694
|
+
if k_char in ['l', 'L'] or key == curses.KEY_RIGHT:
|
|
638
695
|
if self.forward_stack:
|
|
639
696
|
next_view = self.forward_stack.pop()
|
|
640
697
|
self.view_stack.append(next_view)
|
|
@@ -642,7 +699,10 @@ class MyTunesApp:
|
|
|
642
699
|
self.status_msg = ""
|
|
643
700
|
return
|
|
644
701
|
|
|
645
|
-
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
# Fix: Removed Korean mappings ('ㅏ', 'ㅓ') for stability
|
|
705
|
+
if key == curses.KEY_UP or k_char in ['k']:
|
|
646
706
|
if self.selection_idx > 0:
|
|
647
707
|
self.selection_idx -= 1
|
|
648
708
|
if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
|
|
@@ -653,7 +713,7 @@ class MyTunesApp:
|
|
|
653
713
|
# Maintain scroll consistency (h - 10 matches draw() layout)
|
|
654
714
|
list_area_height = h - 10
|
|
655
715
|
self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
|
|
656
|
-
elif key == curses.KEY_DOWN or k_char in ['j'
|
|
716
|
+
elif key == curses.KEY_DOWN or k_char in ['j']:
|
|
657
717
|
if self.selection_idx < len(current_list) - 1:
|
|
658
718
|
self.selection_idx += 1
|
|
659
719
|
h, _ = self.stdscr.getmaxyx()
|
|
@@ -665,26 +725,29 @@ class MyTunesApp:
|
|
|
665
725
|
self.selection_idx = 0
|
|
666
726
|
self.scroll_offset = 0
|
|
667
727
|
|
|
668
|
-
# Enter / Select
|
|
669
|
-
elif key
|
|
670
|
-
|
|
728
|
+
# Enter / Select Logic
|
|
729
|
+
elif key in ['\n', '\r', 10, 13, curses.KEY_ENTER]:
|
|
730
|
+
# v2.0.3 Stability: Debounce Enter to prevent double-firing
|
|
731
|
+
if time.time() - getattr(self, 'last_enter_time', 0) > 0.3:
|
|
732
|
+
self.last_enter_time = time.time()
|
|
733
|
+
self.activate_selection(current_list)
|
|
671
734
|
|
|
672
|
-
# Shortcuts
|
|
673
|
-
# Search: S,
|
|
674
|
-
elif k_char in ['s', 'S', '
|
|
735
|
+
# Shortcuts: Number keys & English letters (Strict Mode)
|
|
736
|
+
# Search: S, 1, /
|
|
737
|
+
elif k_char in ['s', 'S', '1', '/'] and (not isinstance(key, str) or key.isprintable()):
|
|
675
738
|
self.forward_stack = [] # Clear forward history on new navigation
|
|
676
739
|
self.prompt_search()
|
|
677
740
|
|
|
678
|
-
# Favorites: F,
|
|
679
|
-
elif k_char in ['f', 'F', '
|
|
741
|
+
# Favorites: F, 2
|
|
742
|
+
elif k_char in ['f', 'F', '2']:
|
|
680
743
|
if self.view_stack[-1] != "favorites":
|
|
681
744
|
self.forward_stack = []
|
|
682
745
|
self.view_stack.append("favorites")
|
|
683
746
|
self.selection_idx = 0
|
|
684
747
|
self.status_msg = self.t("favorites_info", DATA_FILE)
|
|
685
748
|
|
|
686
|
-
# History: R,
|
|
687
|
-
elif k_char in ['r', 'R', '
|
|
749
|
+
# History: R, 3 (Changed from H to avoid Back conflict)
|
|
750
|
+
elif k_char in ['r', 'R', '3']:
|
|
688
751
|
if self.view_stack[-1] != "history":
|
|
689
752
|
self.forward_stack = []
|
|
690
753
|
self.cached_history = list(self.dm.data['history']) # Snapshot
|
|
@@ -692,8 +755,8 @@ class MyTunesApp:
|
|
|
692
755
|
self.selection_idx = 0
|
|
693
756
|
self.status_msg = self.t("hist_info")
|
|
694
757
|
|
|
695
|
-
# Main Menu: M,
|
|
696
|
-
elif k_char in ['m', 'M', '
|
|
758
|
+
# Main Menu: M, 4
|
|
759
|
+
elif k_char in ['m', 'M', '4']:
|
|
697
760
|
self.forward_stack = [] # Clear forward history
|
|
698
761
|
self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
|
|
699
762
|
|
|
@@ -753,32 +816,36 @@ class MyTunesApp:
|
|
|
753
816
|
}
|
|
754
817
|
|
|
755
818
|
# v1.9.9 Security Update: Use centralized API with Auth Header
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
self.status_msg =
|
|
773
|
-
|
|
774
|
-
|
|
819
|
+
# v2.0.0 Threading for Smoothness
|
|
820
|
+
def send_share_async(payload, headers, url_to_share, title_to_share):
|
|
821
|
+
try:
|
|
822
|
+
resp = requests.post(
|
|
823
|
+
self.share_api_url,
|
|
824
|
+
json=payload,
|
|
825
|
+
headers=headers,
|
|
826
|
+
timeout=3
|
|
827
|
+
)
|
|
828
|
+
if resp.status_code == 200:
|
|
829
|
+
self.sent_history[url_to_share] = time.time()
|
|
830
|
+
safe_t = self.truncate(title_to_share, 50)
|
|
831
|
+
self.status_msg = f"🚀 Shared: {safe_t}..."
|
|
832
|
+
else:
|
|
833
|
+
self.status_msg = f"❌ Share Error: {resp.status_code}"
|
|
834
|
+
except:
|
|
835
|
+
self.status_msg = "❌ Network Error (API)"
|
|
836
|
+
|
|
837
|
+
headers = {
|
|
838
|
+
"Content-Type": "application/json",
|
|
839
|
+
"x-mytunes-secret": "mytunes-v1-secret-8822"
|
|
840
|
+
}
|
|
841
|
+
threading.Thread(target=send_share_async, args=(payload, headers, url, title), daemon=True).start()
|
|
775
842
|
|
|
776
843
|
except Exception as e:
|
|
777
844
|
self.status_msg = f"❌ Share Failed: {str(e)}"
|
|
778
845
|
|
|
779
846
|
|
|
780
|
-
# Add to Favorites: A,
|
|
781
|
-
elif k_char in ['a', 'A', '
|
|
847
|
+
# Add to Favorites: A, 5
|
|
848
|
+
elif k_char in ['a', 'A', '5']:
|
|
782
849
|
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
783
850
|
target_item = current_list[self.selection_idx]
|
|
784
851
|
# Ensure it's a valid track item (has url)
|
|
@@ -795,88 +862,57 @@ class MyTunesApp:
|
|
|
795
862
|
if self.is_remote():
|
|
796
863
|
self.show_copy_dialog("YouTube", url)
|
|
797
864
|
else:
|
|
798
|
-
|
|
799
|
-
self.status_msg = "🌐 Opening YouTube in Browser..."
|
|
800
|
-
threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
|
|
865
|
+
self.open_browser(url)
|
|
801
866
|
|
|
802
|
-
# Open Live Station
|
|
867
|
+
# Open Live Station: F8
|
|
803
868
|
elif key == curses.KEY_F8:
|
|
804
|
-
|
|
869
|
+
homepage_url = "https://mytunes-pro.com"
|
|
805
870
|
if self.is_remote():
|
|
806
|
-
self.show_copy_dialog("
|
|
871
|
+
self.show_copy_dialog("MyTunes Home", homepage_url)
|
|
807
872
|
return
|
|
808
873
|
|
|
809
|
-
|
|
810
|
-
# Subprocess/cmd.exe based launching in WSL is unstable.
|
|
811
|
-
# We switch to the standard `webbrowser` module which handles system default browser reliably.
|
|
812
|
-
# This sacrifices window sizing but guarantees the URL opens.
|
|
813
|
-
if self.is_wsl():
|
|
814
|
-
threading.Thread(target=webbrowser.open, args=(live_url,), daemon=True).start()
|
|
815
|
-
return
|
|
874
|
+
self.open_browser(homepage_url, app_mode=True)
|
|
816
875
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Google\\Chrome\\Application\\chrome.exe'),
|
|
856
|
-
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Google\\Chrome\\Application\\chrome.exe'),
|
|
857
|
-
]
|
|
858
|
-
for p in win_paths:
|
|
859
|
-
if p and os.path.exists(p):
|
|
860
|
-
try:
|
|
861
|
-
subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
862
|
-
launched = True; break
|
|
863
|
-
except: pass
|
|
864
|
-
|
|
876
|
+
# Delete Item: DEL, d
|
|
877
|
+
elif key == curses.KEY_DC or k_char in ['d']:
|
|
878
|
+
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
879
|
+
view = self.view_stack[-1]
|
|
880
|
+
success = False
|
|
881
|
+
|
|
882
|
+
if view == "favorites":
|
|
883
|
+
success = self.dm.remove_favorite_by_index(self.selection_idx)
|
|
884
|
+
if success: self.status_msg = "🗑️ Deleted from Favorites"
|
|
885
|
+
|
|
886
|
+
elif view == "history":
|
|
887
|
+
success = self.dm.remove_history_by_index(self.selection_idx)
|
|
888
|
+
if success:
|
|
889
|
+
self.cached_history = list(self.dm.data['history']) # Refresh view
|
|
890
|
+
self.status_msg = "🗑️ Deleted from History"
|
|
891
|
+
|
|
892
|
+
elif view == "search":
|
|
893
|
+
# If current_search_query is None, we are viewing Search History
|
|
894
|
+
if self.current_search_query is None:
|
|
895
|
+
success = self.dm.remove_search_history_by_index(self.selection_idx)
|
|
896
|
+
if success:
|
|
897
|
+
self.search_results = self.dm.get_search_history() # Refresh
|
|
898
|
+
self.status_msg = "🗑️ Deleted from Search History"
|
|
899
|
+
else:
|
|
900
|
+
# Ephemeral removal from result list
|
|
901
|
+
try:
|
|
902
|
+
self.search_results.pop(self.selection_idx)
|
|
903
|
+
self.status_msg = "Start new search"
|
|
904
|
+
success = True
|
|
905
|
+
except: pass
|
|
906
|
+
|
|
907
|
+
if success:
|
|
908
|
+
# Adjust selection index if out of bounds
|
|
909
|
+
# If list became empty, idx will be 0 but len is 0.
|
|
910
|
+
# We just need to ensure we don't crash next draw.
|
|
911
|
+
# The draw logic (get_current_list) handles empty lists safely.
|
|
912
|
+
if self.selection_idx >= len(self.get_current_list()):
|
|
913
|
+
self.selection_idx = max(0, len(self.get_current_list()) - 1)
|
|
865
914
|
|
|
866
|
-
# 4. Native Linux
|
|
867
|
-
else:
|
|
868
|
-
for b in ['google-chrome', 'google-chrome-stable', 'brave-browser', 'chromium-browser', 'chromium']:
|
|
869
|
-
p = shutil.which(b)
|
|
870
|
-
if p:
|
|
871
|
-
try:
|
|
872
|
-
subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); launched = True; break
|
|
873
|
-
except: pass
|
|
874
915
|
|
|
875
|
-
if launched:
|
|
876
|
-
self.status_msg = "📡 Opening Live Popup (712x800)..."
|
|
877
|
-
else:
|
|
878
|
-
webbrowser.open(live_url)
|
|
879
|
-
self.status_msg = "📡 Opening Live Station (Browser)..."
|
|
880
916
|
|
|
881
917
|
def ask_resume(self, saved_time, track_title):
|
|
882
918
|
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
@@ -937,7 +973,62 @@ class MyTunesApp:
|
|
|
937
973
|
return res
|
|
938
974
|
|
|
939
975
|
def is_remote(self):
|
|
940
|
-
|
|
976
|
+
"""Check if running in a remote SSH session (excluding local WSL)."""
|
|
977
|
+
if 'WSL_DISTRO_NAME' in os.environ or 'WSL_INTEROP' in os.environ:
|
|
978
|
+
return False
|
|
979
|
+
return 'SSH_CONNECTION' in os.environ or 'SSH_CLIENT' in os.environ or 'SSH_TTY' in os.environ
|
|
980
|
+
|
|
981
|
+
def open_browser(self, url, app_mode=False):
|
|
982
|
+
"""Open browser using detached subprocess to prevent TUI freezing."""
|
|
983
|
+
self.status_msg = f"🌐 Opening Link: {url[:30]}..."
|
|
984
|
+
|
|
985
|
+
def run_open():
|
|
986
|
+
try:
|
|
987
|
+
# Prepare DEVNULL for fire-and-forget
|
|
988
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
989
|
+
popen_kwargs = {
|
|
990
|
+
'stdin': devnull,
|
|
991
|
+
'stdout': devnull,
|
|
992
|
+
'stderr': devnull,
|
|
993
|
+
'close_fds': True
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
# Use start_new_session for process group detachment (if possible)
|
|
997
|
+
if hasattr(os, 'setsid') or sys.platform != 'win32':
|
|
998
|
+
popen_kwargs['start_new_session'] = True
|
|
999
|
+
|
|
1000
|
+
if sys.platform == 'darwin':
|
|
1001
|
+
if app_mode:
|
|
1002
|
+
# Attempt "App Mode" for Chrome/Brave on macOS
|
|
1003
|
+
launched = False
|
|
1004
|
+
browsers = [
|
|
1005
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1006
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
1007
|
+
]
|
|
1008
|
+
for b_path in browsers:
|
|
1009
|
+
if os.path.exists(b_path):
|
|
1010
|
+
try:
|
|
1011
|
+
subprocess.Popen([b_path, f"--app={url}", "--window-size=600,800"], **popen_kwargs)
|
|
1012
|
+
launched = True
|
|
1013
|
+
break
|
|
1014
|
+
except: pass
|
|
1015
|
+
if not launched:
|
|
1016
|
+
subprocess.Popen(['open', url], **popen_kwargs)
|
|
1017
|
+
else:
|
|
1018
|
+
subprocess.Popen(['open', url], **popen_kwargs)
|
|
1019
|
+
elif self.is_wsl():
|
|
1020
|
+
# For WSL, we usually use cmd.exe /c start
|
|
1021
|
+
subprocess.Popen(['cmd.exe', '/c', 'start', url], **popen_kwargs)
|
|
1022
|
+
else:
|
|
1023
|
+
# Linux or others
|
|
1024
|
+
subprocess.Popen(['xdg-open', url], **popen_kwargs)
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
# Log error silently to TUI status
|
|
1027
|
+
self.status_msg = f"❌ Browser Error: {str(e)[:20]}"
|
|
1028
|
+
|
|
1029
|
+
# Still execute Popen in a thread to be extra safe,
|
|
1030
|
+
# but Popen itself is now detached and redirected.
|
|
1031
|
+
threading.Thread(target=run_open, daemon=True).start()
|
|
941
1032
|
|
|
942
1033
|
def is_wsl(self):
|
|
943
1034
|
try:
|
|
@@ -1017,13 +1108,9 @@ class MyTunesApp:
|
|
|
1017
1108
|
self.status_msg = "" # Clear stale messages on language switch
|
|
1018
1109
|
elif item["id"] == "quit": self.running = False
|
|
1019
1110
|
else:
|
|
1020
|
-
# Check for Load More Button
|
|
1021
|
-
if item.get("id") == "load_more_btn":
|
|
1022
|
-
self.load_more_results()
|
|
1023
|
-
return
|
|
1024
|
-
|
|
1025
1111
|
self.play_music(item, interactive=True)
|
|
1026
1112
|
|
|
1113
|
+
|
|
1027
1114
|
def play_music(self, item, interactive=True, preserve_queue=False):
|
|
1028
1115
|
if not item.get("url"): return # Guard against dummy items
|
|
1029
1116
|
|
|
@@ -1178,7 +1265,8 @@ class MyTunesApp:
|
|
|
1178
1265
|
if query:
|
|
1179
1266
|
self.status_msg = self.t("searching")
|
|
1180
1267
|
self.draw()
|
|
1181
|
-
|
|
1268
|
+
# v2.0.0 Refactor: Threaded Search
|
|
1269
|
+
threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
|
|
1182
1270
|
else:
|
|
1183
1271
|
# Revert if no query and we were just previewing history
|
|
1184
1272
|
# But requirement 2: "If Enter with no query, preserve previous search results"
|
|
@@ -1187,50 +1275,38 @@ class MyTunesApp:
|
|
|
1187
1275
|
# If the user wants to CANCEL and go back to Main, they might need ESC.
|
|
1188
1276
|
pass
|
|
1189
1277
|
|
|
1190
|
-
def perform_search(self, query
|
|
1278
|
+
def perform_search(self, query):
|
|
1191
1279
|
try:
|
|
1192
|
-
|
|
1193
|
-
if
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
# Resolve yt-dlp path: checks dirname of current python (venv/bin) first
|
|
1280
|
+
# v2.0.4 Fix: Don't set player.loading=True for Search.
|
|
1281
|
+
# It triggers playback timeout (skipping) logic if search is slow.
|
|
1282
|
+
# self.player.loading = True
|
|
1283
|
+
|
|
1284
|
+
self.current_search_query = query
|
|
1285
|
+
self.status_msg = self.t("searching")
|
|
1286
|
+
|
|
1287
|
+
# Resolve yt-dlp path
|
|
1202
1288
|
yt_dlp_cmd = "yt-dlp"
|
|
1203
1289
|
venv_bin = os.path.dirname(sys.executable)
|
|
1204
1290
|
venv_yt_dlp = os.path.join(venv_bin, "yt-dlp")
|
|
1205
1291
|
if os.path.exists(venv_yt_dlp) and os.access(venv_yt_dlp, os.X_OK):
|
|
1206
1292
|
yt_dlp_cmd = venv_yt_dlp
|
|
1207
1293
|
|
|
1208
|
-
#
|
|
1209
|
-
limit =
|
|
1210
|
-
# yt-dlp logic: ytsearchN asks for N results total.
|
|
1211
|
-
# to get page 2 (51-100), we ask for 100, checking playlist-items indices?
|
|
1212
|
-
# actually ytsearchN with --playlist-start START works.
|
|
1213
|
-
# We ask for (page * limit) because 'ytsearch' usually returns 'up to N'.
|
|
1214
|
-
# If we just ask for 50 with start 51, it might fail depending on yt-dlp version.
|
|
1215
|
-
# Safest is: ytsearch(page*limit) with --playlist-start ((page-1)*limit + 1)
|
|
1216
|
-
|
|
1217
|
-
total_fetch = page * limit
|
|
1218
|
-
start_index = (page - 1) * limit + 1
|
|
1219
|
-
|
|
1294
|
+
# v2.0.2 Optimization: 25 Items (Better space usage per user request)
|
|
1295
|
+
limit = 25
|
|
1220
1296
|
search_query = f"{query} music"
|
|
1221
1297
|
cmd = [
|
|
1222
1298
|
yt_dlp_cmd,
|
|
1223
|
-
f"ytsearch{
|
|
1224
|
-
"--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
|
|
1225
|
-
"--playlist-start", str(start_index)
|
|
1299
|
+
f"ytsearch{limit}:{search_query}",
|
|
1300
|
+
"--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
|
|
1226
1301
|
]
|
|
1227
1302
|
|
|
1228
1303
|
try:
|
|
1229
1304
|
result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8')
|
|
1230
1305
|
except subprocess.CalledProcessError:
|
|
1231
|
-
result = ""
|
|
1306
|
+
result = ""
|
|
1232
1307
|
|
|
1233
1308
|
new = []
|
|
1309
|
+
seen_urls = set()
|
|
1234
1310
|
for line in result.strip().split("\n"):
|
|
1235
1311
|
if line:
|
|
1236
1312
|
try:
|
|
@@ -1239,57 +1315,35 @@ class MyTunesApp:
|
|
|
1239
1315
|
if not url or "http" not in url: url = f"https://www.youtube.com/watch?v={d.get('id')}"
|
|
1240
1316
|
dur = d.get("duration", 0)
|
|
1241
1317
|
dur_str = f"{int(dur)//60}:{int(dur)%60:02d}" if dur else ""
|
|
1242
|
-
|
|
1318
|
+
# Dedup Check
|
|
1319
|
+
if url not in seen_urls:
|
|
1320
|
+
seen_urls.add(url)
|
|
1321
|
+
new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
|
|
1243
1322
|
except: pass
|
|
1244
1323
|
|
|
1324
|
+
# Enforce hard limit
|
|
1325
|
+
new = new[:limit]
|
|
1326
|
+
|
|
1245
1327
|
if new:
|
|
1246
|
-
|
|
1247
|
-
if self.
|
|
1248
|
-
self.
|
|
1249
|
-
|
|
1250
|
-
# Append Load More Button if we got full batch (likely more exists)
|
|
1251
|
-
# Or just always add it if we got results.
|
|
1252
|
-
# Adding it at the end of new list
|
|
1253
|
-
load_more_item = {
|
|
1254
|
-
"title": "[ Next 50 Results... ]" if self.lang == 'en' else "[ 다음 50개 더 보기... ]",
|
|
1255
|
-
"id": "load_more_btn",
|
|
1256
|
-
"url": "", # Dummy
|
|
1257
|
-
"duration": ""
|
|
1258
|
-
}
|
|
1259
|
-
new.append(load_more_item)
|
|
1260
|
-
|
|
1261
|
-
if page == 1:
|
|
1262
|
-
self.search_results = new
|
|
1263
|
-
if self.view_stack[-1] != "search":
|
|
1264
|
-
self.view_stack.append("search")
|
|
1265
|
-
self.selection_idx = 0; self.scroll_offset = 0
|
|
1266
|
-
|
|
1267
|
-
# SAVE to History (Exclude load_more_btn)
|
|
1268
|
-
items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
|
|
1269
|
-
self.dm.add_search_results(items_to_save)
|
|
1270
|
-
|
|
1271
|
-
else:
|
|
1272
|
-
self.search_results.extend(new)
|
|
1273
|
-
# Also save subsequent pages to history
|
|
1274
|
-
items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
|
|
1275
|
-
self.dm.add_search_results(items_to_save)
|
|
1328
|
+
self.search_results = new
|
|
1329
|
+
if self.view_stack[-1] != "search":
|
|
1330
|
+
self.view_stack.append("search")
|
|
1331
|
+
self.selection_idx = 0; self.scroll_offset = 0
|
|
1276
1332
|
|
|
1277
|
-
|
|
1278
|
-
self.
|
|
1333
|
+
# SAVE to History
|
|
1334
|
+
self.dm.add_search_results(new)
|
|
1335
|
+
|
|
1336
|
+
self.status_msg = f"Search Done. ({len(new)} results)"
|
|
1279
1337
|
else:
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
self.status_msg = "No more results."
|
|
1283
|
-
# Remove button if no more
|
|
1284
|
-
if self.search_results and self.search_results[-1].get("id") == "load_more_btn":
|
|
1285
|
-
self.search_results.pop()
|
|
1338
|
+
self.status_msg = self.t("no_results")
|
|
1339
|
+
|
|
1286
1340
|
except Exception as e: self.status_msg = f"Error: {e}"
|
|
1287
1341
|
finally:
|
|
1288
|
-
self.
|
|
1342
|
+
self.player.loading = False
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
|
|
1289
1346
|
|
|
1290
|
-
def load_more_results(self):
|
|
1291
|
-
if self.current_search_query and not self.is_loading_more:
|
|
1292
|
-
self.perform_search(self.current_search_query, self.search_page + 1)
|
|
1293
1347
|
|
|
1294
1348
|
def draw(self):
|
|
1295
1349
|
self.stdscr.erase()
|
|
@@ -1445,18 +1499,15 @@ class MyTunesApp:
|
|
|
1445
1499
|
|
|
1446
1500
|
def check_autoplay(self):
|
|
1447
1501
|
# Auto-play next track from Global Queue
|
|
1502
|
+
# Guard: Don't autoplay if we are currently loading a track
|
|
1503
|
+
if self.player.loading: return
|
|
1504
|
+
|
|
1448
1505
|
try:
|
|
1449
1506
|
is_idle = self.player.get_property("idle-active")
|
|
1450
1507
|
if is_idle and self.current_track and self.queue:
|
|
1451
1508
|
if self.queue_idx + 1 < len(self.queue):
|
|
1452
1509
|
self.queue_idx += 1
|
|
1453
1510
|
next_item = self.queue[self.queue_idx]
|
|
1454
|
-
|
|
1455
|
-
if next_item.get('id') == 'load_more_btn':
|
|
1456
|
-
# TODO: Auto-trigger load more? For now just stop.
|
|
1457
|
-
self.current_track = None
|
|
1458
|
-
return
|
|
1459
|
-
|
|
1460
1511
|
try: self.play_music(next_item, interactive=False, preserve_queue=True)
|
|
1461
1512
|
except: pass
|
|
1462
1513
|
else:
|
|
@@ -1471,6 +1522,14 @@ class MyTunesApp:
|
|
|
1471
1522
|
self.check_autoplay()
|
|
1472
1523
|
self.draw()
|
|
1473
1524
|
self.handle_input()
|
|
1525
|
+
|
|
1526
|
+
# Idle / Sleep Check
|
|
1527
|
+
# If no input for 60s and Paused, slow down loop
|
|
1528
|
+
if time.time() - getattr(self, 'last_input_time', 0) > 60 and self.is_paused:
|
|
1529
|
+
self.stdscr.timeout(1000)
|
|
1530
|
+
else:
|
|
1531
|
+
self.stdscr.timeout(200)
|
|
1532
|
+
|
|
1474
1533
|
except Exception as e:
|
|
1475
1534
|
# v1.8.4 - Global resilience: Catch and log loop errors instead of crashing
|
|
1476
1535
|
try:
|
|
@@ -1480,6 +1539,10 @@ class MyTunesApp:
|
|
|
1480
1539
|
# Small sleep to prevent infinite tight loop on persistent error
|
|
1481
1540
|
time.sleep(0.1)
|
|
1482
1541
|
|
|
1542
|
+
# Cleanup Mouse (Prevent terminal artifacts)
|
|
1543
|
+
try: curses.mousemask(0)
|
|
1544
|
+
except: pass
|
|
1545
|
+
|
|
1483
1546
|
if self.stop_on_exit:
|
|
1484
1547
|
self.player.stop()
|
|
1485
1548
|
self.player.cleanup_orphaned_mpv()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mytunes-pro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
5
5
|
Author-email: loxo <loxo5432@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/postgresql-co-kr/mytunes
|
|
@@ -19,7 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
# 🎵 MyTunes Pro (Korean)
|
|
21
21
|
|
|
22
|
-
## 🚀 Professional TUI Music Player v2.0.
|
|
22
|
+
## 🚀 Professional TUI Music Player v2.0.2
|
|
23
23
|
|
|
24
24
|
MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
|
|
25
25
|
Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
|
|
@@ -184,19 +184,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
184
184
|
| **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
|
|
185
185
|
| **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
|
|
186
186
|
| **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
|
|
187
|
-
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `
|
|
187
|
+
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
|
|
188
|
+
| **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
|
|
188
189
|
| **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
|
|
189
190
|
|
|
190
191
|
### 🧭 기본 탐색
|
|
191
192
|
| 키 | 동작 |
|
|
192
193
|
| :--- | :--- |
|
|
193
194
|
| `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
|
|
194
|
-
| `Enter` / `l` | **선택 / 재생**
|
|
195
|
+
| `Enter` / `l` | **선택 / 재생** |
|
|
195
196
|
| `Space` | 재생 / 일시정지 (Play/Pause) |
|
|
196
197
|
| `-` / `+` | **볼륨 조절** (- / +) |
|
|
197
198
|
| `,` / `.` | 10초 뒤로 / 앞으로 감기 |
|
|
198
199
|
| `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
|
|
199
200
|
| `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
|
|
201
|
+
| `L` | **앞으로 가기** |
|
|
200
202
|
| `/` | **검색** (Vim Style) |
|
|
201
203
|
|
|
202
204
|
---
|
|
@@ -210,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
210
212
|
|
|
211
213
|
# 🎵 MyTunes Pro (English)
|
|
212
214
|
|
|
213
|
-
**Modern CLI YouTube Music Player (
|
|
215
|
+
**Modern CLI YouTube Music Player (v2.0.2)**
|
|
214
216
|
A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
215
217
|
|
|
216
218
|
---
|
|
@@ -291,13 +293,27 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
|
|
|
291
293
|
| **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
|
|
292
294
|
| **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
|
|
293
295
|
| **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
|
|
294
|
-
| **`6`** | **Back** | Go back (Same as `Q`, `
|
|
296
|
+
| **`6`** | **Back** | Go back (Same as `Q`, `h`) |
|
|
297
|
+
| **`L`** | **Forward** | Go forward (`Right Arrow`) |
|
|
295
298
|
| **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
|
|
296
299
|
|
|
297
300
|
---
|
|
298
301
|
|
|
299
302
|
## 🔄 Changelog
|
|
300
303
|
|
|
304
|
+
### v2.0.2 (Stability & Browser Optimization)
|
|
305
|
+
|
|
306
|
+
- **Browser Launch**: Switched to fully decoupled `subprocess.Popen` logic for browser opening. This eliminates occasional TUI freezes when launching YouTube (F7) or Live Station (F8) by bypassing `webbrowser` library limitations.
|
|
307
|
+
- **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
|
|
308
|
+
- **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
|
|
309
|
+
|
|
310
|
+
### v2.0.1 (Keymap Refinement & Version Sync)
|
|
311
|
+
|
|
312
|
+
- **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
|
|
313
|
+
- **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
|
|
314
|
+
- **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
|
|
315
|
+
- **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
|
|
316
|
+
|
|
301
317
|
### v1.9.9 (Domain Migration & Realtime Sync)
|
|
302
318
|
|
|
303
319
|
- **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|