mytunes-pro 1.9.9__py3-none-any.whl → 2.0.1__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
@@ -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
- import pusher
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 = "1.9.9"
35
+ APP_VERSION = "2.0.1"
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 open(DATA_FILE, "w", encoding="utf-8") as f:
128
- json.dump(self.data, f, indent=2, ensure_ascii=False)
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.1) # Fast timeout
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:
@@ -446,16 +467,9 @@ class MyTunesApp:
446
467
  curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
447
468
  print("\033[?1003h") # Enable mouse tracking
448
469
 
449
- # Pusher Client
450
- try:
451
- self.pusher = pusher.Pusher(
452
- app_id='2106370',
453
- key='44e3d7e4957944c867ec',
454
- secret='0be8e65a287bbccc7369',
455
- cluster='ap3',
456
- ssl=True
457
- )
458
- except: self.pusher = None
470
+ # Sharing Client (Serverless Proxy)
471
+ self.pusher = None # Deprecated: Direct Pusher client removed for security
472
+ self.share_api_url = "https://postgresql.co.kr/api/pusher/mytunes"
459
473
  self.sent_history = {}
460
474
 
461
475
 
@@ -530,6 +544,15 @@ class MyTunesApp:
530
544
  elif self.playback_time > 10:
531
545
  self.dm.set_progress(self.current_track['url'], self.playback_time)
532
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
+
533
556
  # 2. Frequent: Pause state (Every 2 loops ~400ms)
534
557
  if self.loop_count % 2 == 0:
535
558
  p = self.player.get_property("pause")
@@ -549,10 +572,6 @@ class MyTunesApp:
549
572
  is_idle = self.player.get_property("idle-active")
550
573
  if is_idle and self.player.loading:
551
574
  self.player.loading = False
552
-
553
- # Timeout fallback for loading state (remains every loop logic)
554
- if self.player.loading and (time.time() - getattr(self.player, 'loading_ts', 0) > 8):
555
- self.player.loading = False
556
575
 
557
576
  # Periodic Save (Throttle 10s)
558
577
  if time.time() - getattr(self, 'last_save_time', 0) > 10:
@@ -577,6 +596,10 @@ class MyTunesApp:
577
596
 
578
597
  if key == -1: return
579
598
 
599
+ # Reset Idle Timer
600
+ self.last_input_time = time.time()
601
+
602
+
580
603
  # Handle formatting: invalid key might be int -1
581
604
 
582
605
  # Resize Info
@@ -587,10 +610,37 @@ class MyTunesApp:
587
610
 
588
611
  # GLOBAL ESC: Background Play (Exit but keep music)
589
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
590
614
  if key == 27 or key == '\x1b':
591
- self.stop_on_exit = False
592
- self.running = False
593
- return
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)
594
644
 
595
645
  # Handle Mouse Click
596
646
  if key == curses.KEY_MOUSE:
@@ -626,9 +676,10 @@ class MyTunesApp:
626
676
  current_list = self.get_current_list()
627
677
 
628
678
  # Navigation logic
629
- # Back: Q, Left Arrow, Backspace, Korean 'ㅂ' (q), h, 6
679
+ # Back: Q, Left Arrow, Backspace, h, 6
680
+ # Fix: Removed Korean mappings ('ㅂ', 'ㅗ') to prevent IME ghost keys per user request
630
681
  if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
631
- k_char in ['q', 'ㅂ', '6', 'h', 'ㅗ']:
682
+ k_char in ['q', '6', 'h']:
632
683
  if len(self.view_stack) > 1:
633
684
  # Pop current view and push to forward stack
634
685
  current_view = self.view_stack.pop()
@@ -641,7 +692,8 @@ class MyTunesApp:
641
692
 
642
693
  # Forward: L, Right Arrow (Browser Style)
643
694
  # Re-visit the view we just popped from
644
- if k_char in ['l', 'L', 'ㅣ'] or key == curses.KEY_RIGHT:
695
+ # Fix: Removed Korean mappings ('ㅣ') to prevent IME ghost keys
696
+ if k_char in ['l', 'L'] or key == curses.KEY_RIGHT:
645
697
  if self.forward_stack:
646
698
  next_view = self.forward_stack.pop()
647
699
  self.view_stack.append(next_view)
@@ -649,7 +701,10 @@ class MyTunesApp:
649
701
  self.status_msg = ""
650
702
  return
651
703
 
652
- if key == curses.KEY_UP or k_char in ['k', 'ㅏ']:
704
+ return
705
+
706
+ # Fix: Removed Korean mappings ('ㅏ', 'ㅓ') for stability
707
+ if key == curses.KEY_UP or k_char in ['k']:
653
708
  if self.selection_idx > 0:
654
709
  self.selection_idx -= 1
655
710
  if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
@@ -660,7 +715,7 @@ class MyTunesApp:
660
715
  # Maintain scroll consistency (h - 10 matches draw() layout)
661
716
  list_area_height = h - 10
662
717
  self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
663
- elif key == curses.KEY_DOWN or k_char in ['j', 'ㅓ']:
718
+ elif key == curses.KEY_DOWN or k_char in ['j']:
664
719
  if self.selection_idx < len(current_list) - 1:
665
720
  self.selection_idx += 1
666
721
  h, _ = self.stdscr.getmaxyx()
@@ -672,26 +727,29 @@ class MyTunesApp:
672
727
  self.selection_idx = 0
673
728
  self.scroll_offset = 0
674
729
 
675
- # Enter / Select: Enter Only (L moved to Forward)
676
- elif key == '\n' or key == 10 or key == 13:
677
- self.activate_selection(current_list)
730
+ # Enter / Select Logic
731
+ elif key in ['\n', '\r', 10, 13, curses.KEY_ENTER]:
732
+ # v2.0.3 Stability: Debounce Enter to prevent double-firing
733
+ if time.time() - getattr(self, 'last_enter_time', 0) > 0.3:
734
+ self.last_enter_time = time.time()
735
+ self.activate_selection(current_list)
678
736
 
679
- # Shortcuts with Korean support AND Number keys (for instant reaction)
680
- # Search: S, ㄴ, 1, /
681
- elif k_char in ['s', 'S', 'ㄴ', '1', '/']:
737
+ # Shortcuts: Number keys & English letters (Strict Mode)
738
+ # Search: S, 1, /
739
+ elif k_char in ['s', 'S', '1', '/'] and (not isinstance(key, str) or key.isprintable()):
682
740
  self.forward_stack = [] # Clear forward history on new navigation
683
741
  self.prompt_search()
684
742
 
685
- # Favorites: F, ㄹ, 2
686
- elif k_char in ['f', 'F', 'ㄹ', '2']:
743
+ # Favorites: F, 2
744
+ elif k_char in ['f', 'F', '2']:
687
745
  if self.view_stack[-1] != "favorites":
688
746
  self.forward_stack = []
689
747
  self.view_stack.append("favorites")
690
748
  self.selection_idx = 0
691
749
  self.status_msg = self.t("favorites_info", DATA_FILE)
692
750
 
693
- # History: R, ㄱ, 3 (Changed from H to avoid Back conflict)
694
- elif k_char in ['r', 'R', 'ㄱ', '3']:
751
+ # History: R, 3 (Changed from H to avoid Back conflict)
752
+ elif k_char in ['r', 'R', '3']:
695
753
  if self.view_stack[-1] != "history":
696
754
  self.forward_stack = []
697
755
  self.cached_history = list(self.dm.data['history']) # Snapshot
@@ -699,8 +757,8 @@ class MyTunesApp:
699
757
  self.selection_idx = 0
700
758
  self.status_msg = self.t("hist_info")
701
759
 
702
- # Main Menu: M, ㅡ, 4
703
- elif k_char in ['m', 'M', 'ㅡ', '4']:
760
+ # Main Menu: M, 4
761
+ elif k_char in ['m', 'M', '4']:
704
762
  self.forward_stack = [] # Clear forward history
705
763
  self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
706
764
 
@@ -750,7 +808,7 @@ class MyTunesApp:
750
808
  self.status_msg = "⚠️ Already Shared Recently!"
751
809
  else:
752
810
  try:
753
- # Send to Pusher
811
+ # Send to Serverless Proxy (Secure)
754
812
  payload = {
755
813
  "title": title,
756
814
  "url": url,
@@ -758,19 +816,38 @@ class MyTunesApp:
758
816
  "country": self.dm.get_country(),
759
817
  "timestamp": time.time()
760
818
  }
761
- if self.pusher:
762
- self.pusher.trigger('mytunes-pro', 'share-track', payload)
763
- self.sent_history[url] = time.time()
764
- safe_title = self.truncate(title, 50)
765
- self.status_msg = f"🚀 Shared: {safe_title}..."
766
- else:
767
- self.status_msg = "❌ Pusher Error"
819
+
820
+ # v1.9.9 Security Update: Use centralized API with Auth Header
821
+ # v2.0.0 Threading for Smoothness
822
+ def send_share_async(payload, headers, url_to_share, title_to_share):
823
+ try:
824
+ resp = requests.post(
825
+ self.share_api_url,
826
+ json=payload,
827
+ headers=headers,
828
+ timeout=3
829
+ )
830
+ if resp.status_code == 200:
831
+ self.sent_history[url_to_share] = time.time()
832
+ safe_t = self.truncate(title_to_share, 50)
833
+ self.status_msg = f"🚀 Shared: {safe_t}..."
834
+ else:
835
+ self.status_msg = f"❌ Share Error: {resp.status_code}"
836
+ except:
837
+ self.status_msg = "❌ Network Error (API)"
838
+
839
+ headers = {
840
+ "Content-Type": "application/json",
841
+ "x-mytunes-secret": "mytunes-v1-secret-8822"
842
+ }
843
+ threading.Thread(target=send_share_async, args=(payload, headers, url, title), daemon=True).start()
844
+
768
845
  except Exception as e:
769
846
  self.status_msg = f"❌ Share Failed: {str(e)}"
770
847
 
771
848
 
772
- # Add to Favorites: A, ㅁ, 5
773
- elif k_char in ['a', 'A', 'ㅁ', '5']:
849
+ # Add to Favorites: A, 5
850
+ elif k_char in ['a', 'A', '5']:
774
851
  if current_list and 0 <= self.selection_idx < len(current_list):
775
852
  target_item = current_list[self.selection_idx]
776
853
  # Ensure it's a valid track item (has url)
@@ -791,84 +868,55 @@ class MyTunesApp:
791
868
  self.status_msg = "🌐 Opening YouTube in Browser..."
792
869
  threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
793
870
 
794
- # Open Live Station (F8): App Mode with Optimized Flags (v1.8.6)
795
871
  elif key == curses.KEY_F8:
796
- live_url = "https://mytunes-pro.com/live/"
872
+ homepage_url = "https://mytunes-pro.com"
797
873
  if self.is_remote():
798
- self.show_copy_dialog("Live Station", live_url)
874
+ self.show_copy_dialog("MyTunes Home", homepage_url)
799
875
  return
800
876
 
801
- # v1.9.4 - Ultimate WSL Fix: Use Standard Webbrowser Module
802
- # Subprocess/cmd.exe based launching in WSL is unstable.
803
- # We switch to the standard `webbrowser` module which handles system default browser reliably.
804
- # This sacrifices window sizing but guarantees the URL opens.
805
- if self.is_wsl():
806
- threading.Thread(target=webbrowser.open, args=(live_url,), daemon=True).start()
807
- return
877
+ self.status_msg = "🌐 Opening MyTunes Home..."
878
+ threading.Thread(target=webbrowser.open, args=(homepage_url,), daemon=True).start()
808
879
 
809
- # Native (Mac/Windows/Linux) Logic Continues Below...
810
- temp_user_data = os.path.join(tempfile.gettempdir(), f"mytunes_v190_{int(time.time() / 10)}")
811
-
812
- # Optimized Flag Set (Context7 Research)
813
- flags = [
814
- f"--app={live_url}",
815
- "--window-size=600,900",
816
- "--window-position=100,100",
817
- f"--user-data-dir={temp_user_data}",
818
- "--no-first-run",
819
- "--no-default-browser-check",
820
- "--disable-default-apps",
821
- "--disable-infobars",
822
- "--disable-translate",
823
- "--disable-features=Translation",
824
- "--disable-save-password-bubble",
825
- "--autoplay-policy=no-user-gesture-required",
826
- "--new-window",
827
- "--disable-extensions"
828
- ]
829
-
830
- launched = False
831
- # v1.8.4 - Subprocess Isolation (start_new_session) to prevent crashes on WSL/Linux
832
- # 1. macOS
833
- if sys.platform == 'darwin':
834
- browsers = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"]
835
- for b_path in browsers:
836
- if os.path.exists(b_path):
837
- try:
838
- # Use -na to open a fresh instance
839
- subprocess.Popen(["open", "-na", b_path, "--args"] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
840
- launched = True; break
841
- except: pass
842
-
843
- # 2. Windows Native
844
- elif sys.platform == 'win32':
845
- win_paths = [
846
- os.path.join(os.environ.get('PROGRAMFILES', ''), 'Google\\Chrome\\Application\\chrome.exe'),
847
- os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Google\\Chrome\\Application\\chrome.exe'),
848
- os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Google\\Chrome\\Application\\chrome.exe'),
849
- ]
850
- for p in win_paths:
851
- if p and os.path.exists(p):
852
- try:
853
- subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
854
- launched = True; break
855
- except: pass
856
-
880
+ # Delete Item: DEL, d
881
+ elif key == curses.KEY_DC or k_char in ['d']:
882
+ if current_list and 0 <= self.selection_idx < len(current_list):
883
+ view = self.view_stack[-1]
884
+ success = False
885
+
886
+ if view == "favorites":
887
+ success = self.dm.remove_favorite_by_index(self.selection_idx)
888
+ if success: self.status_msg = "🗑️ Deleted from Favorites"
889
+
890
+ elif view == "history":
891
+ success = self.dm.remove_history_by_index(self.selection_idx)
892
+ if success:
893
+ self.cached_history = list(self.dm.data['history']) # Refresh view
894
+ self.status_msg = "🗑️ Deleted from History"
895
+
896
+ elif view == "search":
897
+ # If current_search_query is None, we are viewing Search History
898
+ if self.current_search_query is None:
899
+ success = self.dm.remove_search_history_by_index(self.selection_idx)
900
+ if success:
901
+ self.search_results = self.dm.get_search_history() # Refresh
902
+ self.status_msg = "🗑️ Deleted from Search History"
903
+ else:
904
+ # Ephemeral removal from result list
905
+ try:
906
+ self.search_results.pop(self.selection_idx)
907
+ self.status_msg = "Start new search"
908
+ success = True
909
+ except: pass
910
+
911
+ if success:
912
+ # Adjust selection index if out of bounds
913
+ # If list became empty, idx will be 0 but len is 0.
914
+ # We just need to ensure we don't crash next draw.
915
+ # The draw logic (get_current_list) handles empty lists safely.
916
+ if self.selection_idx >= len(self.get_current_list()):
917
+ self.selection_idx = max(0, len(self.get_current_list()) - 1)
857
918
 
858
- # 4. Native Linux
859
- else:
860
- for b in ['google-chrome', 'google-chrome-stable', 'brave-browser', 'chromium-browser', 'chromium']:
861
- p = shutil.which(b)
862
- if p:
863
- try:
864
- subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); launched = True; break
865
- except: pass
866
919
 
867
- if launched:
868
- self.status_msg = "📡 Opening Live Popup (712x800)..."
869
- else:
870
- webbrowser.open(live_url)
871
- self.status_msg = "📡 Opening Live Station (Browser)..."
872
920
 
873
921
  def ask_resume(self, saved_time, track_title):
874
922
  self.stdscr.nodelay(False) # Blocking input for dialog
@@ -1009,13 +1057,9 @@ class MyTunesApp:
1009
1057
  self.status_msg = "" # Clear stale messages on language switch
1010
1058
  elif item["id"] == "quit": self.running = False
1011
1059
  else:
1012
- # Check for Load More Button
1013
- if item.get("id") == "load_more_btn":
1014
- self.load_more_results()
1015
- return
1016
-
1017
1060
  self.play_music(item, interactive=True)
1018
1061
 
1062
+
1019
1063
  def play_music(self, item, interactive=True, preserve_queue=False):
1020
1064
  if not item.get("url"): return # Guard against dummy items
1021
1065
 
@@ -1170,7 +1214,8 @@ class MyTunesApp:
1170
1214
  if query:
1171
1215
  self.status_msg = self.t("searching")
1172
1216
  self.draw()
1173
- self.perform_search(query)
1217
+ # v2.0.0 Refactor: Threaded Search
1218
+ threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
1174
1219
  else:
1175
1220
  # Revert if no query and we were just previewing history
1176
1221
  # But requirement 2: "If Enter with no query, preserve previous search results"
@@ -1179,50 +1224,38 @@ class MyTunesApp:
1179
1224
  # If the user wants to CANCEL and go back to Main, they might need ESC.
1180
1225
  pass
1181
1226
 
1182
- def perform_search(self, query, page=1):
1227
+ def perform_search(self, query):
1183
1228
  try:
1184
- self.is_loading_more = True
1185
- if page == 1:
1186
- self.current_search_query = query
1187
- self.search_page = 1
1188
- self.status_msg = self.t("searching")
1189
- else:
1190
- self.status_msg = "Loading next 50..."
1191
- self.draw() # Force redraw to show status
1192
-
1193
- # Resolve yt-dlp path: checks dirname of current python (venv/bin) first
1229
+ # v2.0.4 Fix: Don't set player.loading=True for Search.
1230
+ # It triggers playback timeout (skipping) logic if search is slow.
1231
+ # self.player.loading = True
1232
+
1233
+ self.current_search_query = query
1234
+ self.status_msg = self.t("searching")
1235
+
1236
+ # Resolve yt-dlp path
1194
1237
  yt_dlp_cmd = "yt-dlp"
1195
1238
  venv_bin = os.path.dirname(sys.executable)
1196
1239
  venv_yt_dlp = os.path.join(venv_bin, "yt-dlp")
1197
1240
  if os.path.exists(venv_yt_dlp) and os.access(venv_yt_dlp, os.X_OK):
1198
1241
  yt_dlp_cmd = venv_yt_dlp
1199
1242
 
1200
- # Optimize search for music/audio
1201
- limit = 50
1202
- # yt-dlp logic: ytsearchN asks for N results total.
1203
- # to get page 2 (51-100), we ask for 100, checking playlist-items indices?
1204
- # actually ytsearchN with --playlist-start START works.
1205
- # We ask for (page * limit) because 'ytsearch' usually returns 'up to N'.
1206
- # If we just ask for 50 with start 51, it might fail depending on yt-dlp version.
1207
- # Safest is: ytsearch(page*limit) with --playlist-start ((page-1)*limit + 1)
1208
-
1209
- total_fetch = page * limit
1210
- start_index = (page - 1) * limit + 1
1211
-
1243
+ # v2.0.2 Optimization: 25 Items (Better space usage per user request)
1244
+ limit = 25
1212
1245
  search_query = f"{query} music"
1213
1246
  cmd = [
1214
1247
  yt_dlp_cmd,
1215
- f"ytsearch{total_fetch}:{search_query}",
1216
- "--dump-json", "--flat-playlist", "--no-playlist", "--skip-download",
1217
- "--playlist-start", str(start_index)
1248
+ f"ytsearch{limit}:{search_query}",
1249
+ "--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
1218
1250
  ]
1219
1251
 
1220
1252
  try:
1221
1253
  result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8')
1222
1254
  except subprocess.CalledProcessError:
1223
- result = "" # Handle error or empty
1255
+ result = ""
1224
1256
 
1225
1257
  new = []
1258
+ seen_urls = set()
1226
1259
  for line in result.strip().split("\n"):
1227
1260
  if line:
1228
1261
  try:
@@ -1231,57 +1264,35 @@ class MyTunesApp:
1231
1264
  if not url or "http" not in url: url = f"https://www.youtube.com/watch?v={d.get('id')}"
1232
1265
  dur = d.get("duration", 0)
1233
1266
  dur_str = f"{int(dur)//60}:{int(dur)%60:02d}" if dur else ""
1234
- new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
1267
+ # Dedup Check
1268
+ if url not in seen_urls:
1269
+ seen_urls.add(url)
1270
+ new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
1235
1271
  except: pass
1236
1272
 
1273
+ # Enforce hard limit
1274
+ new = new[:limit]
1275
+
1237
1276
  if new:
1238
- # Remove previous 'Load More' button if exists
1239
- if self.search_results and self.search_results[-1].get("id") == "load_more_btn":
1240
- self.search_results.pop()
1241
-
1242
- # Append Load More Button if we got full batch (likely more exists)
1243
- # Or just always add it if we got results.
1244
- # Adding it at the end of new list
1245
- load_more_item = {
1246
- "title": "[ Next 50 Results... ]" if self.lang == 'en' else "[ 다음 50개 더 보기... ]",
1247
- "id": "load_more_btn",
1248
- "url": "", # Dummy
1249
- "duration": ""
1250
- }
1251
- new.append(load_more_item)
1252
-
1253
- if page == 1:
1254
- self.search_results = new
1255
- if self.view_stack[-1] != "search":
1256
- self.view_stack.append("search")
1257
- self.selection_idx = 0; self.scroll_offset = 0
1258
-
1259
- # SAVE to History (Exclude load_more_btn)
1260
- items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
1261
- self.dm.add_search_results(items_to_save)
1262
-
1263
- else:
1264
- self.search_results.extend(new)
1265
- # Also save subsequent pages to history
1266
- items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
1267
- self.dm.add_search_results(items_to_save)
1277
+ self.search_results = new
1278
+ if self.view_stack[-1] != "search":
1279
+ self.view_stack.append("search")
1280
+ self.selection_idx = 0; self.scroll_offset = 0
1281
+
1282
+ # SAVE to History
1283
+ self.dm.add_search_results(new)
1268
1284
 
1269
- self.search_page = page
1270
- self.status_msg = f"Search Done. ({len(self.search_results)-1})" # -1 for button
1285
+ self.status_msg = f"Search Done. ({len(new)} results)"
1271
1286
  else:
1272
- if page == 1: self.status_msg = self.t("no_results")
1273
- else:
1274
- self.status_msg = "No more results."
1275
- # Remove button if no more
1276
- if self.search_results and self.search_results[-1].get("id") == "load_more_btn":
1277
- self.search_results.pop()
1287
+ self.status_msg = self.t("no_results")
1288
+
1278
1289
  except Exception as e: self.status_msg = f"Error: {e}"
1279
1290
  finally:
1280
- self.is_loading_more = False
1291
+ self.player.loading = False
1292
+
1293
+
1294
+
1281
1295
 
1282
- def load_more_results(self):
1283
- if self.current_search_query and not self.is_loading_more:
1284
- self.perform_search(self.current_search_query, self.search_page + 1)
1285
1296
 
1286
1297
  def draw(self):
1287
1298
  self.stdscr.erase()
@@ -1437,18 +1448,15 @@ class MyTunesApp:
1437
1448
 
1438
1449
  def check_autoplay(self):
1439
1450
  # Auto-play next track from Global Queue
1451
+ # Guard: Don't autoplay if we are currently loading a track
1452
+ if self.player.loading: return
1453
+
1440
1454
  try:
1441
1455
  is_idle = self.player.get_property("idle-active")
1442
1456
  if is_idle and self.current_track and self.queue:
1443
1457
  if self.queue_idx + 1 < len(self.queue):
1444
1458
  self.queue_idx += 1
1445
1459
  next_item = self.queue[self.queue_idx]
1446
-
1447
- if next_item.get('id') == 'load_more_btn':
1448
- # TODO: Auto-trigger load more? For now just stop.
1449
- self.current_track = None
1450
- return
1451
-
1452
1460
  try: self.play_music(next_item, interactive=False, preserve_queue=True)
1453
1461
  except: pass
1454
1462
  else:
@@ -1463,6 +1471,14 @@ class MyTunesApp:
1463
1471
  self.check_autoplay()
1464
1472
  self.draw()
1465
1473
  self.handle_input()
1474
+
1475
+ # Idle / Sleep Check
1476
+ # If no input for 60s and Paused, slow down loop
1477
+ if time.time() - getattr(self, 'last_input_time', 0) > 60 and self.is_paused:
1478
+ self.stdscr.timeout(1000)
1479
+ else:
1480
+ self.stdscr.timeout(200)
1481
+
1466
1482
  except Exception as e:
1467
1483
  # v1.8.4 - Global resilience: Catch and log loop errors instead of crashing
1468
1484
  try:
@@ -1472,6 +1488,10 @@ class MyTunesApp:
1472
1488
  # Small sleep to prevent infinite tight loop on persistent error
1473
1489
  time.sleep(0.1)
1474
1490
 
1491
+ # Cleanup Mouse (Prevent terminal artifacts)
1492
+ try: curses.mousemask(0)
1493
+ except: pass
1494
+
1475
1495
  if self.stop_on_exit:
1476
1496
  self.player.stop()
1477
1497
  self.player.cleanup_orphaned_mpv()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 1.9.9
3
+ Version: 2.0.1
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,9 +19,11 @@ Dynamic: license-file
19
19
 
20
20
  # 🎵 MyTunes Pro (Korean)
21
21
 
22
- **현대적인 CLI 유튜브 뮤직 플레이어 (v1.9.9)**
23
- 터미널 환경에서 **YouTube 음악을 검색하여 듣는** 가볍고 빠른 키보드 중심의 플레이어입니다.
24
- 한국어 입력 환경에서도 **숫자 키(1~5)**를 통해 지연 없는 쾌적한 조작이 가능합니다.
22
+ ## 🚀 Professional TUI Music Player v2.0.1
23
+
24
+ MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
25
+ Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
26
+ `mpv` 플레이어의 강력한 IPC 제어 기능을 통해 끊김 없는 백그라운드 재생을 지원합니다.
25
27
 
26
28
  > **💡 개발 배경**
27
29
  > 이 프로그램은 하루 종일 터미널을 보는 개발자들이 **작업 흐름을 끊지 않고** 편하게 음악을 듣기 위해 만들어졌습니다.
@@ -182,19 +184,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
182
184
  | **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
183
185
  | **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
184
186
  | **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
185
- | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `H`와 동일) |
187
+ | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
188
+ | **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
186
189
  | **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
187
190
 
188
191
  ### 🧭 기본 탐색
189
192
  | 키 | 동작 |
190
193
  | :--- | :--- |
191
194
  | `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
192
- | `Enter` / `l` | **선택 / 재생** (한글 `ㅣ`도 지원) |
195
+ | `Enter` / `l` | **선택 / 재생** |
193
196
  | `Space` | 재생 / 일시정지 (Play/Pause) |
194
197
  | `-` / `+` | **볼륨 조절** (- / +) |
195
198
  | `,` / `.` | 10초 뒤로 / 앞으로 감기 |
196
199
  | `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
197
200
  | `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
201
+ | `L` | **앞으로 가기** |
198
202
  | `/` | **검색** (Vim Style) |
199
203
 
200
204
  ---
@@ -208,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
208
212
 
209
213
  # 🎵 MyTunes Pro (English)
210
214
 
211
- **Modern CLI YouTube Music Player (v1.9.9)**
215
+ **Modern CLI YouTube Music Player (v2.0.1)**
212
216
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
213
217
 
214
218
  ---
@@ -289,13 +293,21 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
289
293
  | **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
290
294
  | **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
291
295
  | **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
292
- | **`6`** | **Back** | Go back (Same as `Q`, `H`) |
296
+ | **`6`** | **Back** | Go back (Same as `Q`, `h`) |
297
+ | **`L`** | **Forward** | Go forward (`Right Arrow`) |
293
298
  | **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
294
299
 
295
300
  ---
296
301
 
297
302
  ## 🔄 Changelog
298
303
 
304
+ ### v2.0.1 (Keymap Refinement & Version Sync)
305
+
306
+ - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
307
+ - **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
308
+ - **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
309
+ - **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
310
+
299
311
  ### v1.9.9 (Domain Migration & Realtime Sync)
300
312
 
301
313
  - **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
@@ -0,0 +1,8 @@
1
+ mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mytunes/app.py,sha256=i6G_1_8guGJqc_EvzlVzMpYfPSSZL1jB7_Nb30qX1T4,61619
3
+ mytunes_pro-2.0.1.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
4
+ mytunes_pro-2.0.1.dist-info/METADATA,sha256=fqpGMPvQnZxzvgaViPMVrvNPEy29yHV0mH3zT59hyKY,22317
5
+ mytunes_pro-2.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ mytunes_pro-2.0.1.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
7
+ mytunes_pro-2.0.1.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
8
+ mytunes_pro-2.0.1.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- mytunes/app.py,sha256=aTfLWNu5i35h2UBXobIEGR03bBd9t6XowYdc_ggZk18,61028
3
- mytunes_pro-1.9.9.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
4
- mytunes_pro-1.9.9.dist-info/METADATA,sha256=-E4osRv8m7ZDhmhUVLHFiRj8Sa-v5kllG8oAZsq-vfs,21615
5
- mytunes_pro-1.9.9.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
- mytunes_pro-1.9.9.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
7
- mytunes_pro-1.9.9.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
8
- mytunes_pro-1.9.9.dist-info/RECORD,,