mytunes-pro 1.5.2__py3-none-any.whl → 1.9.3__py3-none-any.whl
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/app.py +402 -28
- mytunes_pro-1.9.3.dist-info/METADATA +446 -0
- mytunes_pro-1.9.3.dist-info/RECORD +8 -0
- {mytunes_pro-1.5.2.dist-info → mytunes_pro-1.9.3.dist-info}/WHEEL +1 -1
- mytunes_pro-1.5.2.dist-info/METADATA +0 -357
- mytunes_pro-1.5.2.dist-info/RECORD +0 -8
- {mytunes_pro-1.5.2.dist-info → mytunes_pro-1.9.3.dist-info}/entry_points.txt +0 -0
- {mytunes_pro-1.5.2.dist-info → mytunes_pro-1.9.3.dist-info}/licenses/LICENSE +0 -0
- {mytunes_pro-1.5.2.dist-info → mytunes_pro-1.9.3.dist-info}/top_level.txt +0 -0
mytunes/app.py
CHANGED
|
@@ -16,9 +16,18 @@ import unicodedata
|
|
|
16
16
|
import socket
|
|
17
17
|
import locale
|
|
18
18
|
import signal
|
|
19
|
+
import warnings
|
|
20
|
+
# Suppress urllib3 warning about LibreSSL compatibility
|
|
21
|
+
warnings.filterwarnings("ignore", message=".*urllib3 v2 only supports OpenSSL 1.1.1+.*")
|
|
22
|
+
import webbrowser
|
|
23
|
+
import tempfile
|
|
24
|
+
import shutil
|
|
25
|
+
import pusher
|
|
26
|
+
import requests
|
|
27
|
+
|
|
19
28
|
|
|
20
29
|
# Ensure Unicode support
|
|
21
|
-
locale.setlocale(locale.LC_ALL, '')
|
|
30
|
+
# locale.setlocale(locale.LC_ALL, '')
|
|
22
31
|
|
|
23
32
|
# === [Configuration] ===
|
|
24
33
|
DATA_FILE = os.path.expanduser("~/.pymusic_data.json")
|
|
@@ -26,7 +35,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
|
|
|
26
35
|
LOG_FILE = "/tmp/mytunes_mpv.log"
|
|
27
36
|
PID_FILE = "/tmp/mytunes_mpv.pid"
|
|
28
37
|
APP_NAME = "MyTunes Pro"
|
|
29
|
-
APP_VERSION = "1.
|
|
38
|
+
APP_VERSION = "1.9.3"
|
|
30
39
|
|
|
31
40
|
# === [Strings & Localization] ===
|
|
32
41
|
STRINGS = {
|
|
@@ -45,8 +54,9 @@ STRINGS = {
|
|
|
45
54
|
"stopped": "⏹ 정지됨",
|
|
46
55
|
"fav_added": "★ 즐겨찾기에 추가됨",
|
|
47
56
|
"fav_removed": "☆ 즐겨찾기 해제됨",
|
|
48
|
-
"
|
|
49
|
-
"
|
|
57
|
+
"header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
|
|
58
|
+
"header_r2": "[F7]유튜브 [F8]라이브 [F9]라이브공유 [SPC]Play/Stop [+/-]볼륨 [<>]빨리감기",
|
|
59
|
+
"help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [F7]유튜브 [F8]라이브 [F9]라이브공유",
|
|
50
60
|
"menu_main": "☰ 메인 메뉴",
|
|
51
61
|
"menu_search_results": "⌕ YouTube 음악 검색",
|
|
52
62
|
"menu_favorites": "★ 나의 즐겨찾기",
|
|
@@ -73,8 +83,9 @@ STRINGS = {
|
|
|
73
83
|
"stopped": "⏹ Stopped",
|
|
74
84
|
"fav_added": "★ Added to Favorites",
|
|
75
85
|
"fav_removed": "☆ Removed from Favorites",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
86
|
+
"header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
|
|
87
|
+
"header_r2": "[F7]YT [F8]Live [F9]LiveShare [SPC]Play/Stop [+/-]Vol [<>]Seek",
|
|
88
|
+
"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",
|
|
78
89
|
"menu_main": "☰ Main Menu",
|
|
79
90
|
"menu_search_results": "⌕ Search YouTube Music",
|
|
80
91
|
"menu_favorites": "★ My Favorites",
|
|
@@ -94,16 +105,22 @@ class DataManager:
|
|
|
94
105
|
self.data = self.load_data()
|
|
95
106
|
self.favorites_set = {f['url'] for f in self.data.get('favorites', []) if 'url' in f}
|
|
96
107
|
|
|
108
|
+
# Auto-fetch country if missing
|
|
109
|
+
if 'country' not in self.data:
|
|
110
|
+
threading.Thread(target=self.fetch_country, daemon=True).start()
|
|
111
|
+
|
|
112
|
+
|
|
97
113
|
def load_data(self):
|
|
98
114
|
if not os.path.exists(DATA_FILE):
|
|
99
|
-
return {"history": [], "favorites": [], "language": "ko", "resume": {}}
|
|
115
|
+
return {"history": [], "favorites": [], "language": "ko", "resume": {}, "search_results_history": []}
|
|
100
116
|
try:
|
|
101
117
|
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
|
102
118
|
data = json.load(f)
|
|
103
119
|
if "resume" not in data: data["resume"] = {}
|
|
120
|
+
if "search_results_history" not in data: data["search_results_history"] = []
|
|
104
121
|
return data
|
|
105
122
|
except Exception:
|
|
106
|
-
return {"history": [], "favorites": [], "language": "ko", "resume": {}}
|
|
123
|
+
return {"history": [], "favorites": [], "language": "ko", "resume": {}, "search_results_history": []}
|
|
107
124
|
|
|
108
125
|
def save_data(self):
|
|
109
126
|
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
|
@@ -140,6 +157,78 @@ class DataManager:
|
|
|
140
157
|
def is_favorite(self, url):
|
|
141
158
|
return url in self.favorites_set
|
|
142
159
|
|
|
160
|
+
def fetch_country(self):
|
|
161
|
+
"""Fetch country code asynchronously and save."""
|
|
162
|
+
apis = [
|
|
163
|
+
('https://ipapi.co/json/', 'country_code'),
|
|
164
|
+
('http://ip-api.com/json/', 'countryCode'),
|
|
165
|
+
('https://ipwho.is/', 'country_code')
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
for url, key in apis:
|
|
169
|
+
try:
|
|
170
|
+
resp = requests.get(url, timeout=3)
|
|
171
|
+
if resp.status_code == 200:
|
|
172
|
+
country = resp.json().get(key)
|
|
173
|
+
if country:
|
|
174
|
+
self.data['country'] = country
|
|
175
|
+
self.save_data()
|
|
176
|
+
return
|
|
177
|
+
except:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Fallback to Locale
|
|
181
|
+
try:
|
|
182
|
+
loc, _ = locale.getdefaultlocale()
|
|
183
|
+
if loc:
|
|
184
|
+
country = loc.split('_')[-1]
|
|
185
|
+
self.data['country'] = country
|
|
186
|
+
self.save_data()
|
|
187
|
+
return
|
|
188
|
+
except: pass
|
|
189
|
+
|
|
190
|
+
# Final Fallback
|
|
191
|
+
if 'country' not in self.data:
|
|
192
|
+
self.data['country'] = 'UN'
|
|
193
|
+
self.save_data()
|
|
194
|
+
|
|
195
|
+
def get_country(self):
|
|
196
|
+
# If it's US or UN, maybe it was a mistake or fallback, try to refresh once per session?
|
|
197
|
+
# Actually, let's just use what's there but allow re-fetch if requested.
|
|
198
|
+
return self.data.get('country', 'UN')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_search_history(self):
|
|
202
|
+
return self.data.get('search_results_history', [])
|
|
203
|
+
|
|
204
|
+
def add_search_results(self, items):
|
|
205
|
+
"""Add new search results to history, deduping and limiting to 200."""
|
|
206
|
+
history = self.data.get('search_results_history', [])
|
|
207
|
+
|
|
208
|
+
# Create a set of existing URLs for fast lookup if needed,
|
|
209
|
+
# but since we want to bring duplicates to top or merge,
|
|
210
|
+
# let's just filter out any incoming items that are already in history?
|
|
211
|
+
# Requirement: "Accumulate actual result items... Dedup... Latest first"
|
|
212
|
+
|
|
213
|
+
# Strategy: Prepend new items. Remove duplicates based on URL.
|
|
214
|
+
# 1. Combine new + old
|
|
215
|
+
combined = items + history
|
|
216
|
+
|
|
217
|
+
# 2. Dedup (keep first occurrence)
|
|
218
|
+
seen_urls = set()
|
|
219
|
+
unique_history = []
|
|
220
|
+
for item in combined:
|
|
221
|
+
url = item.get('url')
|
|
222
|
+
if url and url not in seen_urls:
|
|
223
|
+
seen_urls.add(url)
|
|
224
|
+
unique_history.append(item)
|
|
225
|
+
elif not url: # Should not happen for valid items
|
|
226
|
+
unique_history.append(item)
|
|
227
|
+
|
|
228
|
+
# 3. Limit to 200
|
|
229
|
+
self.data['search_results_history'] = unique_history[:200]
|
|
230
|
+
self.save_data()
|
|
231
|
+
|
|
143
232
|
# === [Player Logic with Advanced IPC] ===
|
|
144
233
|
class Player:
|
|
145
234
|
def __init__(self):
|
|
@@ -156,10 +245,12 @@ class Player:
|
|
|
156
245
|
# self.cleanup_orphaned_mpv() # Moved to play() per user request
|
|
157
246
|
|
|
158
247
|
def cleanup_orphaned_mpv(self):
|
|
159
|
-
#
|
|
160
|
-
#
|
|
248
|
+
# Precise pkill to avoid matching the main TUI process
|
|
249
|
+
# Matches 'mpv ' (with space) or 'mpv' as exact process name
|
|
161
250
|
try:
|
|
162
|
-
subprocess.run(["pkill", "-
|
|
251
|
+
subprocess.run(["pkill", "-x", "mpv"], stderr=subprocess.DEVNULL)
|
|
252
|
+
# Second pass for variants or sub-arguments if needed
|
|
253
|
+
subprocess.run(["pkill", "-f", "mpv --video=no"], stderr=subprocess.DEVNULL)
|
|
163
254
|
except: pass
|
|
164
255
|
|
|
165
256
|
def play(self, url, start_pos=0):
|
|
@@ -242,7 +333,6 @@ class Player:
|
|
|
242
333
|
try:
|
|
243
334
|
self.current_proc.terminate()
|
|
244
335
|
self.current_proc.wait(timeout=1)
|
|
245
|
-
self.current_proc.wait(timeout=1)
|
|
246
336
|
except:
|
|
247
337
|
# If terminate fails, try socket quit
|
|
248
338
|
try: self.send_cmd(["quit"])
|
|
@@ -351,6 +441,19 @@ class MyTunesApp:
|
|
|
351
441
|
signal.signal(signal.SIGHUP, self.handle_disconnect)
|
|
352
442
|
except: pass
|
|
353
443
|
|
|
444
|
+
# Pusher Client
|
|
445
|
+
try:
|
|
446
|
+
self.pusher = pusher.Pusher(
|
|
447
|
+
app_id='2106370',
|
|
448
|
+
key='44e3d7e4957944c867ec',
|
|
449
|
+
secret='0be8e65a287bbccc7369',
|
|
450
|
+
cluster='ap3',
|
|
451
|
+
ssl=True
|
|
452
|
+
)
|
|
453
|
+
except: self.pusher = None
|
|
454
|
+
self.sent_history = {}
|
|
455
|
+
|
|
456
|
+
|
|
354
457
|
def handle_disconnect(self, signum, frame):
|
|
355
458
|
"""Auto-background if terminal disconnects."""
|
|
356
459
|
self.stop_on_exit = False
|
|
@@ -517,14 +620,24 @@ class MyTunesApp:
|
|
|
517
620
|
if self.selection_idx > 0:
|
|
518
621
|
self.selection_idx -= 1
|
|
519
622
|
if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
|
|
623
|
+
elif current_list:
|
|
624
|
+
# v1.8.5 - Wrapping: Top to Bottom
|
|
625
|
+
self.selection_idx = len(current_list) - 1
|
|
626
|
+
h, _ = self.stdscr.getmaxyx()
|
|
627
|
+
# Maintain scroll consistency (h - 10 matches draw() layout)
|
|
628
|
+
list_area_height = h - 10
|
|
629
|
+
self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
|
|
520
630
|
elif key == curses.KEY_DOWN or k_char in ['j', 'ㅓ']:
|
|
521
631
|
if self.selection_idx < len(current_list) - 1:
|
|
522
632
|
self.selection_idx += 1
|
|
523
633
|
h, _ = self.stdscr.getmaxyx()
|
|
524
|
-
# Use h - 10 to match inner_h in draw() (h - footer_h(5) - header_top(3) - borders(2))
|
|
525
634
|
list_area_height = h - 10
|
|
526
635
|
if self.selection_idx >= self.scroll_offset + list_area_height:
|
|
527
636
|
self.scroll_offset = self.selection_idx - list_area_height + 1
|
|
637
|
+
elif current_list:
|
|
638
|
+
# v1.8.5 - Wrapping: Bottom to Top
|
|
639
|
+
self.selection_idx = 0
|
|
640
|
+
self.scroll_offset = 0
|
|
528
641
|
|
|
529
642
|
# Enter / Select: Enter Only (L moved to Forward)
|
|
530
643
|
elif key == '\n' or key == 10 or key == 13:
|
|
@@ -582,11 +695,47 @@ class MyTunesApp:
|
|
|
582
695
|
self.player.seek(30)
|
|
583
696
|
self.status_msg = "Forward 30s"
|
|
584
697
|
|
|
585
|
-
# ESC: Background Play (Exit but keep music)
|
|
586
698
|
elif key == 27:
|
|
587
699
|
self.stop_on_exit = False
|
|
588
700
|
self.running = False
|
|
589
701
|
|
|
702
|
+
# Share Track (F9): Real-time Publish
|
|
703
|
+
elif key == curses.KEY_F9:
|
|
704
|
+
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
705
|
+
target_item = current_list[self.selection_idx]
|
|
706
|
+
url = target_item.get('url')
|
|
707
|
+
title = target_item.get('title', 'Unknown Title')
|
|
708
|
+
|
|
709
|
+
if url:
|
|
710
|
+
# If it's US, try to re-fetch country info one more time (maybe misdetected)
|
|
711
|
+
if self.dm.get_country() == 'US':
|
|
712
|
+
threading.Thread(target=self.dm.fetch_country, daemon=True).start()
|
|
713
|
+
|
|
714
|
+
# Dedup Check: Using a time-based cooldown (e.g. 5 seconds) for same URL
|
|
715
|
+
last_sent_time = self.sent_history.get(url, 0)
|
|
716
|
+
if time.time() - last_sent_time < 5:
|
|
717
|
+
self.status_msg = "⚠️ Already Shared Recently!"
|
|
718
|
+
else:
|
|
719
|
+
try:
|
|
720
|
+
# Send to Pusher
|
|
721
|
+
payload = {
|
|
722
|
+
"title": title,
|
|
723
|
+
"url": url,
|
|
724
|
+
"duration": target_item.get('duration', '--:--'),
|
|
725
|
+
"country": self.dm.get_country(),
|
|
726
|
+
"timestamp": time.time()
|
|
727
|
+
}
|
|
728
|
+
if self.pusher:
|
|
729
|
+
self.pusher.trigger('mytunes-global', 'share-track', payload)
|
|
730
|
+
self.sent_history[url] = time.time()
|
|
731
|
+
safe_title = self.truncate(title, 50)
|
|
732
|
+
self.status_msg = f"🚀 Shared: {safe_title}..."
|
|
733
|
+
else:
|
|
734
|
+
self.status_msg = "❌ Pusher Error"
|
|
735
|
+
except Exception as e:
|
|
736
|
+
self.status_msg = f"❌ Share Failed: {str(e)}"
|
|
737
|
+
|
|
738
|
+
|
|
590
739
|
# Add to Favorites: A, ㅁ, 5
|
|
591
740
|
elif k_char in ['a', 'A', 'ㅁ', '5']:
|
|
592
741
|
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
@@ -596,6 +745,121 @@ class MyTunesApp:
|
|
|
596
745
|
is_added = self.dm.toggle_favorite(target_item)
|
|
597
746
|
self.status_msg = self.t("fav_added") if is_added else self.t("fav_removed")
|
|
598
747
|
|
|
748
|
+
# Open in Browser (YouTube): F7
|
|
749
|
+
elif key == curses.KEY_F7:
|
|
750
|
+
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
751
|
+
target_item = current_list[self.selection_idx]
|
|
752
|
+
url = target_item.get('url')
|
|
753
|
+
if url:
|
|
754
|
+
if self.is_remote():
|
|
755
|
+
self.show_copy_dialog("YouTube", url)
|
|
756
|
+
else:
|
|
757
|
+
# v1.8.4 - Use standard webbrowser library for maximum stability on F7
|
|
758
|
+
self.status_msg = "🌐 Opening YouTube in Browser..."
|
|
759
|
+
threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
|
|
760
|
+
|
|
761
|
+
# Open Live Station (F8): App Mode with Optimized Flags (v1.8.6)
|
|
762
|
+
elif key == curses.KEY_F8:
|
|
763
|
+
live_url = "https://mytunes.postgresql.co.kr/live/"
|
|
764
|
+
if self.is_remote():
|
|
765
|
+
self.show_copy_dialog("Live Station", live_url)
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
# v1.9.2 - Critical WSL Fix: Disable Profile Isolation
|
|
769
|
+
# Executing cmd.exe or managing paths in WSL has proven unstable due to environment differences.
|
|
770
|
+
# We explicitly DISABLE profile isolation in WSL, using the default Chrome profile data.
|
|
771
|
+
# This ensures stability at the cost of session isolation.
|
|
772
|
+
if self.is_wsl():
|
|
773
|
+
temp_user_data = None
|
|
774
|
+
else:
|
|
775
|
+
temp_user_data = os.path.join(tempfile.gettempdir(), f"mytunes_v190_{int(time.time() / 10)}")
|
|
776
|
+
|
|
777
|
+
# Optimized Flag Set (Context7 Research)
|
|
778
|
+
flags = [
|
|
779
|
+
f"--app={live_url}",
|
|
780
|
+
"--window-size=712,800",
|
|
781
|
+
"--window-position=100,100",
|
|
782
|
+
"--no-first-run",
|
|
783
|
+
"--no-default-browser-check",
|
|
784
|
+
"--disable-default-apps",
|
|
785
|
+
"--disable-infobars",
|
|
786
|
+
"--disable-translate",
|
|
787
|
+
"--disable-features=Translation",
|
|
788
|
+
"--disable-save-password-bubble",
|
|
789
|
+
"--autoplay-policy=no-user-gesture-required",
|
|
790
|
+
"--new-window",
|
|
791
|
+
"--disable-extensions"
|
|
792
|
+
]
|
|
793
|
+
|
|
794
|
+
# Only add user-data-dir if we have a valid path (Non-WSL)
|
|
795
|
+
if temp_user_data:
|
|
796
|
+
flags.append(f"--user-data-dir={temp_user_data}")
|
|
797
|
+
|
|
798
|
+
launched = False
|
|
799
|
+
# v1.8.4 - Subprocess Isolation (start_new_session) to prevent crashes on WSL/Linux
|
|
800
|
+
# 1. macOS
|
|
801
|
+
if sys.platform == 'darwin':
|
|
802
|
+
browsers = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"]
|
|
803
|
+
for b_path in browsers:
|
|
804
|
+
if os.path.exists(b_path):
|
|
805
|
+
try:
|
|
806
|
+
# Use -na to open a fresh instance
|
|
807
|
+
subprocess.Popen(["open", "-na", b_path, "--args"] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
808
|
+
launched = True; break
|
|
809
|
+
except: pass
|
|
810
|
+
|
|
811
|
+
# 2. Windows Native
|
|
812
|
+
elif sys.platform == 'win32':
|
|
813
|
+
win_paths = [
|
|
814
|
+
os.path.join(os.environ.get('PROGRAMFILES', ''), 'Google\\Chrome\\Application\\chrome.exe'),
|
|
815
|
+
os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Google\\Chrome\\Application\\chrome.exe'),
|
|
816
|
+
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Google\\Chrome\\Application\\chrome.exe'),
|
|
817
|
+
]
|
|
818
|
+
for p in win_paths:
|
|
819
|
+
if p and os.path.exists(p):
|
|
820
|
+
try:
|
|
821
|
+
subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
822
|
+
launched = True; break
|
|
823
|
+
except: pass
|
|
824
|
+
|
|
825
|
+
# 3. WSL / Linux (Direct path with session isolation)
|
|
826
|
+
elif self.is_wsl():
|
|
827
|
+
wsl_paths = [
|
|
828
|
+
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
|
|
829
|
+
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
830
|
+
"/mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe",
|
|
831
|
+
]
|
|
832
|
+
for p in wsl_paths:
|
|
833
|
+
if os.path.exists(p):
|
|
834
|
+
try:
|
|
835
|
+
# CRITICAL: start_new_session=True isolates the browser from the TUI process group
|
|
836
|
+
# This prevents the TUI from dying when navigating or if the browser has shell issues.
|
|
837
|
+
subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
|
838
|
+
launched = True; break
|
|
839
|
+
except: pass
|
|
840
|
+
|
|
841
|
+
if not launched:
|
|
842
|
+
try:
|
|
843
|
+
# Fallback for WSL to CMD (path is already converted upstream)
|
|
844
|
+
subprocess.Popen(["cmd.exe", "/c", f"start chrome --app={live_url} --user-data-dir={temp_user_data}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
|
845
|
+
launched = True
|
|
846
|
+
except: pass
|
|
847
|
+
|
|
848
|
+
# 4. Native Linux
|
|
849
|
+
else:
|
|
850
|
+
for b in ['google-chrome', 'google-chrome-stable', 'brave-browser', 'chromium-browser', 'chromium']:
|
|
851
|
+
p = shutil.which(b)
|
|
852
|
+
if p:
|
|
853
|
+
try:
|
|
854
|
+
subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); launched = True; break
|
|
855
|
+
except: pass
|
|
856
|
+
|
|
857
|
+
if launched:
|
|
858
|
+
self.status_msg = "📡 Opening Live Popup (712x800)..."
|
|
859
|
+
else:
|
|
860
|
+
webbrowser.open(live_url)
|
|
861
|
+
self.status_msg = "📡 Opening Live Station (Browser)..."
|
|
862
|
+
|
|
599
863
|
def ask_resume(self, saved_time, track_title):
|
|
600
864
|
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
601
865
|
h, w = self.stdscr.getmaxyx()
|
|
@@ -654,6 +918,61 @@ class MyTunesApp:
|
|
|
654
918
|
self.stdscr.refresh()
|
|
655
919
|
return res
|
|
656
920
|
|
|
921
|
+
def is_remote(self):
|
|
922
|
+
return 'SSH_CONNECTION' in os.environ or 'SSH_CLIENT' in os.environ
|
|
923
|
+
|
|
924
|
+
def is_wsl(self):
|
|
925
|
+
try:
|
|
926
|
+
if sys.platform != 'linux': return False
|
|
927
|
+
if os.path.exists('/proc/version'):
|
|
928
|
+
with open('/proc/version', 'r') as f:
|
|
929
|
+
return 'microsoft' in f.read().lower()
|
|
930
|
+
return False
|
|
931
|
+
except: return False
|
|
932
|
+
|
|
933
|
+
def show_copy_dialog(self, title, url):
|
|
934
|
+
"""Show a dialog with the URL for manual copying in remote sessions."""
|
|
935
|
+
self.stdscr.nodelay(False)
|
|
936
|
+
h, w = self.stdscr.getmaxyx()
|
|
937
|
+
box_h, box_w = 8, min(80, w - 4)
|
|
938
|
+
box_y, box_x = (h - box_h) // 2, (w - box_w) // 2
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
win = curses.newwin(box_h, box_w, box_y, box_x)
|
|
942
|
+
win.keypad(True)
|
|
943
|
+
try: win.bkgd(' ', curses.color_pair(1))
|
|
944
|
+
except: pass
|
|
945
|
+
|
|
946
|
+
win.attron(curses.color_pair(1)); win.box()
|
|
947
|
+
|
|
948
|
+
# Title
|
|
949
|
+
header = " Remote Link " if self.lang == 'en' else " 원격 링크 "
|
|
950
|
+
win.addstr(0, 2, header, curses.A_BOLD | curses.color_pair(3))
|
|
951
|
+
|
|
952
|
+
# Content
|
|
953
|
+
lbl = "Open this URL in your local browser:" if self.lang == 'en' else "아래 주소를 로컬 브라우저에서여세요:"
|
|
954
|
+
win.addstr(2, 3, lbl, curses.color_pair(1))
|
|
955
|
+
|
|
956
|
+
# URL (Truncate if needed but try to show mostly)
|
|
957
|
+
disp_url = self.truncate(url, box_w - 6)
|
|
958
|
+
win.addstr(3, 3, disp_url, curses.color_pair(5) | curses.A_BOLD)
|
|
959
|
+
|
|
960
|
+
# Exit instruction
|
|
961
|
+
exit_msg = "[Enter/ESC] Close" if self.lang == 'en' else "[Enter/ESC] 닫기"
|
|
962
|
+
win.addstr(6, box_w - len(exit_msg) - 2, exit_msg, curses.color_pair(1))
|
|
963
|
+
|
|
964
|
+
win.refresh()
|
|
965
|
+
curses.flushinp()
|
|
966
|
+
|
|
967
|
+
# Wait for key
|
|
968
|
+
while True:
|
|
969
|
+
k = win.getch()
|
|
970
|
+
if k in [10, 13, curses.KEY_ENTER, 27, ord(' ')]:
|
|
971
|
+
break
|
|
972
|
+
except: pass
|
|
973
|
+
finally:
|
|
974
|
+
self.stdscr.timeout(200) # Restore non-blocking
|
|
975
|
+
|
|
657
976
|
def activate_selection(self, items):
|
|
658
977
|
if not items: return
|
|
659
978
|
item = items[self.selection_idx]
|
|
@@ -814,12 +1133,41 @@ class MyTunesApp:
|
|
|
814
1133
|
return "".join(chars).strip()
|
|
815
1134
|
|
|
816
1135
|
def prompt_search(self):
|
|
817
|
-
curses.flushinp()
|
|
1136
|
+
curses.flushinp()
|
|
1137
|
+
|
|
1138
|
+
orig_view = self.view_stack[-1]
|
|
1139
|
+
orig_results = list(self.search_results)
|
|
1140
|
+
|
|
1141
|
+
# Show search history in background using existing 'search' view
|
|
1142
|
+
history = self.dm.get_search_history()
|
|
1143
|
+
if history:
|
|
1144
|
+
self.search_results = history
|
|
1145
|
+
self.selection_idx = 0
|
|
1146
|
+
self.scroll_offset = 0
|
|
1147
|
+
if self.view_stack[-1] != "search":
|
|
1148
|
+
self.view_stack.append("search")
|
|
1149
|
+
self.status_msg = "" # Clear "List is empty" etc.
|
|
1150
|
+
self.draw()
|
|
1151
|
+
|
|
818
1152
|
query = self.input_dialog(self.t("search_label"), self.t("search_prompt"))
|
|
1153
|
+
|
|
1154
|
+
# Handling query result
|
|
1155
|
+
# Note: If user pressed ESC, input_dialog returns "" (per current implementation)
|
|
1156
|
+
# But wait, input_dialog logic: "ESC -> chars = []; break; return "".join(chars).strip()"
|
|
1157
|
+
# So ESC and empty Enter both return "".
|
|
1158
|
+
# I should check if it's possible to distinguish.
|
|
1159
|
+
|
|
819
1160
|
if query:
|
|
820
1161
|
self.status_msg = self.t("searching")
|
|
821
1162
|
self.draw()
|
|
822
1163
|
self.perform_search(query)
|
|
1164
|
+
else:
|
|
1165
|
+
# Revert if no query and we were just previewing history
|
|
1166
|
+
# But requirement 2: "If Enter with no query, preserve previous search results"
|
|
1167
|
+
# This is tricky because ESC and empty Enter currently both return "".
|
|
1168
|
+
# I will assume "" means "keep current view (history)".
|
|
1169
|
+
# If the user wants to CANCEL and go back to Main, they might need ESC.
|
|
1170
|
+
pass
|
|
823
1171
|
|
|
824
1172
|
def perform_search(self, query, page=1):
|
|
825
1173
|
try:
|
|
@@ -897,8 +1245,16 @@ class MyTunesApp:
|
|
|
897
1245
|
if self.view_stack[-1] != "search":
|
|
898
1246
|
self.view_stack.append("search")
|
|
899
1247
|
self.selection_idx = 0; self.scroll_offset = 0
|
|
1248
|
+
|
|
1249
|
+
# SAVE to History (Exclude load_more_btn)
|
|
1250
|
+
items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
|
|
1251
|
+
self.dm.add_search_results(items_to_save)
|
|
1252
|
+
|
|
900
1253
|
else:
|
|
901
1254
|
self.search_results.extend(new)
|
|
1255
|
+
# Also save subsequent pages to history
|
|
1256
|
+
items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
|
|
1257
|
+
self.dm.add_search_results(items_to_save)
|
|
902
1258
|
|
|
903
1259
|
self.search_page = page
|
|
904
1260
|
self.status_msg = f"Search Done. ({len(self.search_results)-1})" # -1 for button
|
|
@@ -925,14 +1281,23 @@ class MyTunesApp:
|
|
|
925
1281
|
self.stdscr.addstr(0, 0, "Window too small!")
|
|
926
1282
|
return
|
|
927
1283
|
|
|
928
|
-
# Header (
|
|
929
|
-
self.draw_box(self.stdscr, 0, 0,
|
|
1284
|
+
# Header (4 lines)
|
|
1285
|
+
self.draw_box(self.stdscr, 0, 0, 4, w, APP_NAME)
|
|
930
1286
|
title = self.t("title", APP_VERSION)
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1287
|
+
|
|
1288
|
+
# Row 1: Nav
|
|
1289
|
+
r1 = self.t("header_r1")
|
|
1290
|
+
gap1 = w - 4 - self.get_display_width(title) - self.get_display_width(r1)
|
|
1291
|
+
if gap1 < 2: gap1 = 2
|
|
1292
|
+
line1 = f"{title}{' '*gap1}{r1}"
|
|
1293
|
+
self.stdscr.addstr(1, 2, self.truncate(line1, w-4), curses.color_pair(1) | curses.A_BOLD)
|
|
1294
|
+
|
|
1295
|
+
# Row 2: Actions
|
|
1296
|
+
r2 = self.t("header_r2")
|
|
1297
|
+
gap2 = w - 4 - self.get_display_width(r2)
|
|
1298
|
+
if gap2 < 2: gap2 = 2
|
|
1299
|
+
line2 = f"{' '*gap2}{r2}"
|
|
1300
|
+
self.stdscr.addstr(2, 2, self.truncate(line2, w-4), curses.color_pair(1) | curses.A_BOLD)
|
|
936
1301
|
|
|
937
1302
|
# Footer (5 lines)
|
|
938
1303
|
footer_h = 5
|
|
@@ -977,7 +1342,7 @@ class MyTunesApp:
|
|
|
977
1342
|
self.stdscr.addstr(h - 2, 2, f"📢 {msg}", attr)
|
|
978
1343
|
|
|
979
1344
|
# List Area (Remaining Middle)
|
|
980
|
-
list_top =
|
|
1345
|
+
list_top = 4
|
|
981
1346
|
list_h = h - footer_h - list_top
|
|
982
1347
|
self.draw_box(self.stdscr, list_top, 0, list_h, w)
|
|
983
1348
|
|
|
@@ -1082,11 +1447,20 @@ class MyTunesApp:
|
|
|
1082
1447
|
|
|
1083
1448
|
def run(self):
|
|
1084
1449
|
while self.running:
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1450
|
+
try:
|
|
1451
|
+
self.loop_count = (self.loop_count + 1) % 1000
|
|
1452
|
+
self.update_playback_state()
|
|
1453
|
+
self.check_autoplay()
|
|
1454
|
+
self.draw()
|
|
1455
|
+
self.handle_input()
|
|
1456
|
+
except Exception as e:
|
|
1457
|
+
# v1.8.4 - Global resilience: Catch and log loop errors instead of crashing
|
|
1458
|
+
try:
|
|
1459
|
+
with open("/tmp/mytunes_error.log", "a") as f:
|
|
1460
|
+
f.write(f"[{time.ctime()}] Loop Error: {str(e)}\n")
|
|
1461
|
+
except: pass
|
|
1462
|
+
# Small sleep to prevent infinite tight loop on persistent error
|
|
1463
|
+
time.sleep(0.1)
|
|
1090
1464
|
|
|
1091
1465
|
if self.stop_on_exit:
|
|
1092
1466
|
self.player.stop()
|