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 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.5.2"
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
- "header_help": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐찾추가 [SPC]재생/정지 [Vol]+/- [Q/6]이전",
49
- "help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐찾추가 [SPC]재생/정지",
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
- "header_help": "[S/1]Search [F/2]Favs [R/3]Hist [M/4]Main [A/5]Add Fav [SPC]Play/Pause [Vol]+/- [Q/6]Back",
77
- "help_guide": "[j/k]Move [En]Select [h/q]Back [S/1]Srch [F/2]Fav [R/3]Hist [M/4]Main [A/5]Add Fav [SPC]P/P",
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
- # User requested revert to aggressive pkill for reliability
160
- # This ensures any previous background instances are killed
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", "-f", "mpv"], stderr=subprocess.DEVNULL)
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() # Clear any buffered keys
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 (3 lines)
929
- self.draw_box(self.stdscr, 0, 0, 3, w, APP_NAME)
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
- help_txt = self.t("header_help")
932
- gap = w - 4 - self.get_display_width(title) - self.get_display_width(help_txt)
933
- if gap < 2: gap = 2
934
- hdr_txt = f"{title}{' '*gap}{help_txt}"
935
- self.stdscr.addstr(1, 2, self.truncate(hdr_txt, w-4), curses.color_pair(1) | curses.A_BOLD)
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 = 3
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
- self.loop_count = (self.loop_count + 1) % 1000
1086
- self.update_playback_state()
1087
- self.check_autoplay()
1088
- self.draw()
1089
- self.handle_input()
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()