mytunes-pro 2.0.0__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
-
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.0"
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:
@@ -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
- self.stop_on_exit = False
585
- self.running = False
586
- 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)
587
644
 
588
645
  # Handle Mouse Click
589
646
  if key == curses.KEY_MOUSE:
@@ -619,9 +676,10 @@ class MyTunesApp:
619
676
  current_list = self.get_current_list()
620
677
 
621
678
  # Navigation logic
622
- # 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
623
681
  if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
624
- k_char in ['q', 'ㅂ', '6', 'h', 'ㅗ']:
682
+ k_char in ['q', '6', 'h']:
625
683
  if len(self.view_stack) > 1:
626
684
  # Pop current view and push to forward stack
627
685
  current_view = self.view_stack.pop()
@@ -634,7 +692,8 @@ class MyTunesApp:
634
692
 
635
693
  # Forward: L, Right Arrow (Browser Style)
636
694
  # Re-visit the view we just popped from
637
- 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:
638
697
  if self.forward_stack:
639
698
  next_view = self.forward_stack.pop()
640
699
  self.view_stack.append(next_view)
@@ -642,7 +701,10 @@ class MyTunesApp:
642
701
  self.status_msg = ""
643
702
  return
644
703
 
645
- 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']:
646
708
  if self.selection_idx > 0:
647
709
  self.selection_idx -= 1
648
710
  if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
@@ -653,7 +715,7 @@ class MyTunesApp:
653
715
  # Maintain scroll consistency (h - 10 matches draw() layout)
654
716
  list_area_height = h - 10
655
717
  self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
656
- elif key == curses.KEY_DOWN or k_char in ['j', 'ㅓ']:
718
+ elif key == curses.KEY_DOWN or k_char in ['j']:
657
719
  if self.selection_idx < len(current_list) - 1:
658
720
  self.selection_idx += 1
659
721
  h, _ = self.stdscr.getmaxyx()
@@ -665,26 +727,29 @@ class MyTunesApp:
665
727
  self.selection_idx = 0
666
728
  self.scroll_offset = 0
667
729
 
668
- # Enter / Select: Enter Only (L moved to Forward)
669
- elif key == '\n' or key == 10 or key == 13:
670
- 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)
671
736
 
672
- # Shortcuts with Korean support AND Number keys (for instant reaction)
673
- # Search: S, ㄴ, 1, /
674
- 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()):
675
740
  self.forward_stack = [] # Clear forward history on new navigation
676
741
  self.prompt_search()
677
742
 
678
- # Favorites: F, ㄹ, 2
679
- elif k_char in ['f', 'F', 'ㄹ', '2']:
743
+ # Favorites: F, 2
744
+ elif k_char in ['f', 'F', '2']:
680
745
  if self.view_stack[-1] != "favorites":
681
746
  self.forward_stack = []
682
747
  self.view_stack.append("favorites")
683
748
  self.selection_idx = 0
684
749
  self.status_msg = self.t("favorites_info", DATA_FILE)
685
750
 
686
- # History: R, ㄱ, 3 (Changed from H to avoid Back conflict)
687
- 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']:
688
753
  if self.view_stack[-1] != "history":
689
754
  self.forward_stack = []
690
755
  self.cached_history = list(self.dm.data['history']) # Snapshot
@@ -692,8 +757,8 @@ class MyTunesApp:
692
757
  self.selection_idx = 0
693
758
  self.status_msg = self.t("hist_info")
694
759
 
695
- # Main Menu: M, ㅡ, 4
696
- elif k_char in ['m', 'M', 'ㅡ', '4']:
760
+ # Main Menu: M, 4
761
+ elif k_char in ['m', 'M', '4']:
697
762
  self.forward_stack = [] # Clear forward history
698
763
  self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
699
764
 
@@ -753,32 +818,36 @@ class MyTunesApp:
753
818
  }
754
819
 
755
820
  # v1.9.9 Security Update: Use centralized API with Auth Header
756
- try:
757
- headers = {
758
- "Content-Type": "application/json",
759
- "x-mytunes-secret": "mytunes-v1-secret-8822"
760
- }
761
- resp = requests.post(
762
- self.share_api_url,
763
- json=payload,
764
- headers=headers,
765
- timeout=3
766
- )
767
- if resp.status_code == 200:
768
- self.sent_history[url] = time.time()
769
- safe_title = self.truncate(title, 50)
770
- self.status_msg = f"🚀 Shared: {safe_title}..."
771
- else:
772
- self.status_msg = f"❌ Share Error: {resp.status_code}"
773
- except:
774
- self.status_msg = "❌ Network Error (API)"
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()
775
844
 
776
845
  except Exception as e:
777
846
  self.status_msg = f"❌ Share Failed: {str(e)}"
778
847
 
779
848
 
780
- # Add to Favorites: A, ㅁ, 5
781
- elif k_char in ['a', 'A', 'ㅁ', '5']:
849
+ # Add to Favorites: A, 5
850
+ elif k_char in ['a', 'A', '5']:
782
851
  if current_list and 0 <= self.selection_idx < len(current_list):
783
852
  target_item = current_list[self.selection_idx]
784
853
  # Ensure it's a valid track item (has url)
@@ -799,84 +868,55 @@ class MyTunesApp:
799
868
  self.status_msg = "🌐 Opening YouTube in Browser..."
800
869
  threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
801
870
 
802
- # Open Live Station (F8): App Mode with Optimized Flags (v1.8.6)
803
871
  elif key == curses.KEY_F8:
804
- live_url = "https://mytunes-pro.com/live/"
872
+ homepage_url = "https://mytunes-pro.com"
805
873
  if self.is_remote():
806
- self.show_copy_dialog("Live Station", live_url)
874
+ self.show_copy_dialog("MyTunes Home", homepage_url)
807
875
  return
808
876
 
809
- # v1.9.4 - Ultimate WSL Fix: Use Standard Webbrowser Module
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
877
+ self.status_msg = "🌐 Opening MyTunes Home..."
878
+ threading.Thread(target=webbrowser.open, args=(homepage_url,), daemon=True).start()
816
879
 
817
- # Native (Mac/Windows/Linux) Logic Continues Below...
818
- temp_user_data = os.path.join(tempfile.gettempdir(), f"mytunes_v190_{int(time.time() / 10)}")
819
-
820
- # Optimized Flag Set (Context7 Research)
821
- flags = [
822
- f"--app={live_url}",
823
- "--window-size=600,900",
824
- "--window-position=100,100",
825
- f"--user-data-dir={temp_user_data}",
826
- "--no-first-run",
827
- "--no-default-browser-check",
828
- "--disable-default-apps",
829
- "--disable-infobars",
830
- "--disable-translate",
831
- "--disable-features=Translation",
832
- "--disable-save-password-bubble",
833
- "--autoplay-policy=no-user-gesture-required",
834
- "--new-window",
835
- "--disable-extensions"
836
- ]
837
-
838
- launched = False
839
- # v1.8.4 - Subprocess Isolation (start_new_session) to prevent crashes on WSL/Linux
840
- # 1. macOS
841
- if sys.platform == 'darwin':
842
- browsers = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"]
843
- for b_path in browsers:
844
- if os.path.exists(b_path):
845
- try:
846
- # Use -na to open a fresh instance
847
- subprocess.Popen(["open", "-na", b_path, "--args"] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
848
- launched = True; break
849
- except: pass
850
-
851
- # 2. Windows Native
852
- elif sys.platform == 'win32':
853
- win_paths = [
854
- os.path.join(os.environ.get('PROGRAMFILES', ''), 'Google\\Chrome\\Application\\chrome.exe'),
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
-
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)
865
918
 
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
919
 
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
920
 
881
921
  def ask_resume(self, saved_time, track_title):
882
922
  self.stdscr.nodelay(False) # Blocking input for dialog
@@ -1017,13 +1057,9 @@ class MyTunesApp:
1017
1057
  self.status_msg = "" # Clear stale messages on language switch
1018
1058
  elif item["id"] == "quit": self.running = False
1019
1059
  else:
1020
- # Check for Load More Button
1021
- if item.get("id") == "load_more_btn":
1022
- self.load_more_results()
1023
- return
1024
-
1025
1060
  self.play_music(item, interactive=True)
1026
1061
 
1062
+
1027
1063
  def play_music(self, item, interactive=True, preserve_queue=False):
1028
1064
  if not item.get("url"): return # Guard against dummy items
1029
1065
 
@@ -1178,7 +1214,8 @@ class MyTunesApp:
1178
1214
  if query:
1179
1215
  self.status_msg = self.t("searching")
1180
1216
  self.draw()
1181
- self.perform_search(query)
1217
+ # v2.0.0 Refactor: Threaded Search
1218
+ threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
1182
1219
  else:
1183
1220
  # Revert if no query and we were just previewing history
1184
1221
  # But requirement 2: "If Enter with no query, preserve previous search results"
@@ -1187,50 +1224,38 @@ class MyTunesApp:
1187
1224
  # If the user wants to CANCEL and go back to Main, they might need ESC.
1188
1225
  pass
1189
1226
 
1190
- def perform_search(self, query, page=1):
1227
+ def perform_search(self, query):
1191
1228
  try:
1192
- self.is_loading_more = True
1193
- if page == 1:
1194
- self.current_search_query = query
1195
- self.search_page = 1
1196
- self.status_msg = self.t("searching")
1197
- else:
1198
- self.status_msg = "Loading next 50..."
1199
- self.draw() # Force redraw to show status
1200
-
1201
- # 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
1202
1237
  yt_dlp_cmd = "yt-dlp"
1203
1238
  venv_bin = os.path.dirname(sys.executable)
1204
1239
  venv_yt_dlp = os.path.join(venv_bin, "yt-dlp")
1205
1240
  if os.path.exists(venv_yt_dlp) and os.access(venv_yt_dlp, os.X_OK):
1206
1241
  yt_dlp_cmd = venv_yt_dlp
1207
1242
 
1208
- # Optimize search for music/audio
1209
- limit = 50
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
-
1243
+ # v2.0.2 Optimization: 25 Items (Better space usage per user request)
1244
+ limit = 25
1220
1245
  search_query = f"{query} music"
1221
1246
  cmd = [
1222
1247
  yt_dlp_cmd,
1223
- f"ytsearch{total_fetch}:{search_query}",
1224
- "--dump-json", "--flat-playlist", "--no-playlist", "--skip-download",
1225
- "--playlist-start", str(start_index)
1248
+ f"ytsearch{limit}:{search_query}",
1249
+ "--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
1226
1250
  ]
1227
1251
 
1228
1252
  try:
1229
1253
  result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8')
1230
1254
  except subprocess.CalledProcessError:
1231
- result = "" # Handle error or empty
1255
+ result = ""
1232
1256
 
1233
1257
  new = []
1258
+ seen_urls = set()
1234
1259
  for line in result.strip().split("\n"):
1235
1260
  if line:
1236
1261
  try:
@@ -1239,57 +1264,35 @@ class MyTunesApp:
1239
1264
  if not url or "http" not in url: url = f"https://www.youtube.com/watch?v={d.get('id')}"
1240
1265
  dur = d.get("duration", 0)
1241
1266
  dur_str = f"{int(dur)//60}:{int(dur)%60:02d}" if dur else ""
1242
- 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})
1243
1271
  except: pass
1244
1272
 
1273
+ # Enforce hard limit
1274
+ new = new[:limit]
1275
+
1245
1276
  if new:
1246
- # Remove previous 'Load More' button if exists
1247
- if self.search_results and self.search_results[-1].get("id") == "load_more_btn":
1248
- self.search_results.pop()
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)
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)
1276
1284
 
1277
- self.search_page = page
1278
- self.status_msg = f"Search Done. ({len(self.search_results)-1})" # -1 for button
1285
+ self.status_msg = f"Search Done. ({len(new)} results)"
1279
1286
  else:
1280
- if page == 1: self.status_msg = self.t("no_results")
1281
- else:
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()
1287
+ self.status_msg = self.t("no_results")
1288
+
1286
1289
  except Exception as e: self.status_msg = f"Error: {e}"
1287
1290
  finally:
1288
- self.is_loading_more = False
1291
+ self.player.loading = False
1292
+
1293
+
1294
+
1289
1295
 
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
1296
 
1294
1297
  def draw(self):
1295
1298
  self.stdscr.erase()
@@ -1445,18 +1448,15 @@ class MyTunesApp:
1445
1448
 
1446
1449
  def check_autoplay(self):
1447
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
+
1448
1454
  try:
1449
1455
  is_idle = self.player.get_property("idle-active")
1450
1456
  if is_idle and self.current_track and self.queue:
1451
1457
  if self.queue_idx + 1 < len(self.queue):
1452
1458
  self.queue_idx += 1
1453
1459
  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
1460
  try: self.play_music(next_item, interactive=False, preserve_queue=True)
1461
1461
  except: pass
1462
1462
  else:
@@ -1471,6 +1471,14 @@ class MyTunesApp:
1471
1471
  self.check_autoplay()
1472
1472
  self.draw()
1473
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
+
1474
1482
  except Exception as e:
1475
1483
  # v1.8.4 - Global resilience: Catch and log loop errors instead of crashing
1476
1484
  try:
@@ -1480,6 +1488,10 @@ class MyTunesApp:
1480
1488
  # Small sleep to prevent infinite tight loop on persistent error
1481
1489
  time.sleep(0.1)
1482
1490
 
1491
+ # Cleanup Mouse (Prevent terminal artifacts)
1492
+ try: curses.mousemask(0)
1493
+ except: pass
1494
+
1483
1495
  if self.stop_on_exit:
1484
1496
  self.player.stop()
1485
1497
  self.player.cleanup_orphaned_mpv()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 2.0.0
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,7 +19,7 @@ Dynamic: license-file
19
19
 
20
20
  # 🎵 MyTunes Pro (Korean)
21
21
 
22
- ## 🚀 Professional TUI Music Player v2.0.0
22
+ ## 🚀 Professional TUI Music Player v2.0.1
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`, `H`와 동일) |
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 (v1.9.9)**
215
+ **Modern CLI YouTube Music Player (v2.0.1)**
214
216
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
215
217
 
216
218
  ---
@@ -291,13 +293,21 @@ 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`, `H`) |
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.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
+
301
311
  ### v1.9.9 (Domain Migration & Realtime Sync)
302
312
 
303
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=tcrf-GgzuNcQrg_Bi8agLUUBagK0ldZu6jG34rGi32Q,61718
3
- mytunes_pro-2.0.0.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
4
- mytunes_pro-2.0.0.dist-info/METADATA,sha256=nABpiFFx5gtAAScvQG7Ps8XLH3NPeh6CtU4QQZ0HH04,21706
5
- mytunes_pro-2.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
- mytunes_pro-2.0.0.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
7
- mytunes_pro-2.0.0.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
8
- mytunes_pro-2.0.0.dist-info/RECORD,,