mytunes-pro 2.0.4__py3-none-any.whl → 2.0.6__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 +505 -99
- {mytunes_pro-2.0.4.dist-info → mytunes_pro-2.0.6.dist-info}/METADATA +191 -32
- mytunes_pro-2.0.6.dist-info/RECORD +8 -0
- mytunes_pro-2.0.4.dist-info/RECORD +0 -8
- {mytunes_pro-2.0.4.dist-info → mytunes_pro-2.0.6.dist-info}/WHEEL +0 -0
- {mytunes_pro-2.0.4.dist-info → mytunes_pro-2.0.6.dist-info}/entry_points.txt +0 -0
- {mytunes_pro-2.0.4.dist-info → mytunes_pro-2.0.6.dist-info}/licenses/LICENSE +0 -0
- {mytunes_pro-2.0.4.dist-info → mytunes_pro-2.0.6.dist-info}/top_level.txt +0 -0
mytunes/app.py
CHANGED
|
@@ -4,6 +4,18 @@ MyTunes Pro - Professional TUI Edition v1.0
|
|
|
4
4
|
# Premium CLI Media Workflow Experiment with Curses Interface
|
|
5
5
|
Enhanced with Context7-researched MPV IPC & Resize Handling
|
|
6
6
|
"""
|
|
7
|
+
import warnings
|
|
8
|
+
# Suppress urllib3 NotOpenSSLWarning (LibreSSL compatibility)
|
|
9
|
+
# This must be defined before ANY imports that might trigger urllib3
|
|
10
|
+
warnings.filterwarnings("ignore", message=".*urllib3 v2 only supports OpenSSL 1.1.1+.*")
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import urllib3
|
|
14
|
+
# Optional: also ignore by category if already imported
|
|
15
|
+
from urllib3.exceptions import NotOpenSSLWarning
|
|
16
|
+
warnings.filterwarnings("ignore", category=NotOpenSSLWarning)
|
|
17
|
+
except: pass
|
|
18
|
+
|
|
7
19
|
import curses
|
|
8
20
|
import curses.textpad
|
|
9
21
|
import json
|
|
@@ -32,7 +44,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
|
|
|
32
44
|
LOG_FILE = "/tmp/mytunes_mpv.log"
|
|
33
45
|
PID_FILE = "/tmp/mytunes_mpv.pid"
|
|
34
46
|
APP_NAME = "MyTunes Pro"
|
|
35
|
-
APP_VERSION = "2.0.
|
|
47
|
+
APP_VERSION = "2.0.6"
|
|
36
48
|
|
|
37
49
|
# === [Strings & Localization] ===
|
|
38
50
|
STRINGS = {
|
|
@@ -52,7 +64,8 @@ STRINGS = {
|
|
|
52
64
|
"fav_added": "★ 즐겨찾기에 추가됨",
|
|
53
65
|
"fav_removed": "☆ 즐겨찾기 해제됨",
|
|
54
66
|
"header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
|
|
55
|
-
"
|
|
67
|
+
"header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
|
|
68
|
+
"header_r2": "[F7]유튜브 [E]이퀄라이저 [SPC]Play/Stop [+/-]볼륨 [D/Del]삭제",
|
|
56
69
|
"help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [F7]유튜브",
|
|
57
70
|
"menu_main": "☰ 메인 메뉴",
|
|
58
71
|
"menu_search_results": "⌕ 미디어 콘텐츠 검색",
|
|
@@ -63,7 +76,9 @@ STRINGS = {
|
|
|
63
76
|
"favorites_info": "즐겨찾기 저장 위치: {}",
|
|
64
77
|
"hist_info": "최근 재생 기록 (최대 100곡)",
|
|
65
78
|
"time_fmt": "{}/{}",
|
|
66
|
-
"vol_fmt": "볼륨: {}%"
|
|
79
|
+
"vol_fmt": "볼륨: {}%",
|
|
80
|
+
"ime_warning": "영문 모드로 전환 후 단축키를 눌러주세요.",
|
|
81
|
+
"invalid_key": "잘못된 키 입력: '{}'"
|
|
67
82
|
},
|
|
68
83
|
"en": {
|
|
69
84
|
"title": "MyTunes Pro v{}",
|
|
@@ -81,7 +96,8 @@ STRINGS = {
|
|
|
81
96
|
"fav_added": "★ Added to Favorites",
|
|
82
97
|
"fav_removed": "☆ Removed from Favorites",
|
|
83
98
|
"header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
|
|
84
|
-
"
|
|
99
|
+
"header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
|
|
100
|
+
"header_r2": "[F7]YT [E]EQ [SPC]Play/Stop [+/-]Vol [<>]Seek [D/Del]Del",
|
|
85
101
|
"help_guide": "[j/k]Move [En]Select [h/q]Back [S/1]Srch [F/2]Fav [R/3]Hist [M/4]Main [F7]YT",
|
|
86
102
|
"menu_main": "☰ Main Menu",
|
|
87
103
|
"menu_search_results": "⌕ Search Media Content",
|
|
@@ -92,9 +108,32 @@ STRINGS = {
|
|
|
92
108
|
"favorites_info": "Favorites stored at: {}",
|
|
93
109
|
"hist_info": "Recent Playback History (Max 100)",
|
|
94
110
|
"time_fmt": "{}/{}",
|
|
95
|
-
"vol_fmt": "Vol: {}%"
|
|
111
|
+
"vol_fmt": "Vol: {}%",
|
|
112
|
+
"ime_warning": "Switch to English for shortcuts.",
|
|
113
|
+
"invalid_key": "Invalid key: '{}'"
|
|
96
114
|
}
|
|
115
|
+
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# === [Audio Presets] ===
|
|
119
|
+
# === [Audio Presets] ===
|
|
120
|
+
# Standard "Most Used" Presets (Amplified/Professional tuned)
|
|
121
|
+
# Professional EQ Presets (10-Band: 32Hz, 64Hz, 125Hz, 250Hz, 500Hz, 1kHz, 2kHz, 4kHz, 8kHz, 16kHz)
|
|
122
|
+
# Based on: Harman Target Curve, Dolby Atmos Guidelines, AES Standards
|
|
123
|
+
# Reference: Harman International (AKG, JBL, Harman Kardon), Sennheiser HD800S, Dolby Labs
|
|
124
|
+
EQUALIZER_PRESETS = {
|
|
125
|
+
"Flat": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], # Reference: No coloration
|
|
126
|
+
"Pop": [1, 2, 4, 3, 1, 2, 3, 4, 3, 1], # Harman-inspired: Vocal clarity, balanced warmth
|
|
127
|
+
"Rock": [4, 5, 3, 1, -1, 1, 2, 4, 5, 4], # V-Curve: Powerful bass, crisp highs, recessed mids
|
|
128
|
+
"Jazz": [3, 4, 3, 2, 1, 0, 1, 2, 3, 4], # Warm: Natural bass, smooth highs, open soundstage
|
|
129
|
+
"Classical": [2, 3, 2, 1, 0, 0, 1, 2, 3, 4], # Neutral: Flat mids, airy highs (Dolby-inspired)
|
|
130
|
+
"Full Bass": [6, 7, 6, 4, 2, 0, 0, 0, 0, 0], # Sub-bass focus: Clean low-end (AKG K371 style)
|
|
131
|
+
"Dance": [5, 6, 4, 2, 0, 1, 2, 4, 4, 3], # Club: Punchy kick, clear hi-hats
|
|
132
|
+
"Club": [4, 5, 6, 4, 2, 0, 1, 3, 3, 2], # EDM: Deep sub, mid-bass punch (Harman bass shelf)
|
|
133
|
+
"Live": [2, 3, 2, 1, 2, 3, 2, 3, 4, 3], # Concert: Natural reverb, presence boost
|
|
134
|
+
"Soft": [2, 3, 2, 1, 0, 0, -1, 0, 1, 2] # Relaxed: Gentle bass, rolled-off harshness
|
|
97
135
|
}
|
|
136
|
+
EQUALIZER_KEYS = ["Auto"] + list(EQUALIZER_PRESETS.keys())
|
|
98
137
|
|
|
99
138
|
class DataManager:
|
|
100
139
|
def __init__(self):
|
|
@@ -272,7 +311,7 @@ class Player:
|
|
|
272
311
|
subprocess.run(["pkill", "-f", "mpv --video=no"], stderr=subprocess.DEVNULL)
|
|
273
312
|
except: pass
|
|
274
313
|
|
|
275
|
-
def play(self, url, start_pos=0):
|
|
314
|
+
def play(self, url, start_pos=0, initial_eq_preset="Flat"):
|
|
276
315
|
# 1. Try to reuse existing instance via IPC (Graceful)
|
|
277
316
|
if os.path.exists(MPV_SOCKET):
|
|
278
317
|
try:
|
|
@@ -281,6 +320,11 @@ class Player:
|
|
|
281
320
|
if resp and not resp.get("error"):
|
|
282
321
|
if start_pos > 0:
|
|
283
322
|
self.send_cmd(["seek", str(start_pos), "absolute"])
|
|
323
|
+
|
|
324
|
+
# Apply EQ immediately for reused instance
|
|
325
|
+
if initial_eq_preset and initial_eq_preset != "Flat":
|
|
326
|
+
self.set_equalizer(initial_eq_preset)
|
|
327
|
+
|
|
284
328
|
self.loading = True
|
|
285
329
|
self.loading_ts = time.time()
|
|
286
330
|
return # Success! No need to restart
|
|
@@ -302,10 +346,16 @@ class Player:
|
|
|
302
346
|
"mpv", "--video=no", "--vo=null", "--force-window=no",
|
|
303
347
|
"--audio-display=no", "--no-config",
|
|
304
348
|
f"--input-ipc-server={MPV_SOCKET}",
|
|
305
|
-
"--idle=yes"
|
|
306
|
-
url
|
|
349
|
+
"--idle=yes"
|
|
307
350
|
]
|
|
308
351
|
|
|
352
|
+
# Inject Initial EQ (0ms Latency)
|
|
353
|
+
eq_af = self._get_eq_af_string(initial_eq_preset)
|
|
354
|
+
if eq_af:
|
|
355
|
+
cmd.append(f"--af={eq_af}")
|
|
356
|
+
|
|
357
|
+
cmd.append(url)
|
|
358
|
+
|
|
309
359
|
# B. macOS Specific UI Optimizations
|
|
310
360
|
if sys.platform == "darwin":
|
|
311
361
|
# 'accessory' hides Dock but allows system resources
|
|
@@ -404,6 +454,25 @@ class Player:
|
|
|
404
454
|
"""Seek relative to current position."""
|
|
405
455
|
self.send_cmd(["seek", seconds, "relative"])
|
|
406
456
|
|
|
457
|
+
def _get_eq_af_string(self, preset_name):
|
|
458
|
+
"""Generate lavfi string for EQ preset."""
|
|
459
|
+
gains = EQUALIZER_PRESETS.get(preset_name, EQUALIZER_PRESETS["Flat"])
|
|
460
|
+
if preset_name == "Flat": return ""
|
|
461
|
+
|
|
462
|
+
freqs = [31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
|
463
|
+
filters = []
|
|
464
|
+
for f, g in zip(freqs, gains):
|
|
465
|
+
if g != 0:
|
|
466
|
+
filters.append(f"equalizer=f={f}:width_type=o:width=1:g={g}")
|
|
467
|
+
|
|
468
|
+
if not filters: return ""
|
|
469
|
+
return "lavfi=[" + ",".join(filters) + "]"
|
|
470
|
+
|
|
471
|
+
def set_equalizer(self, preset_name):
|
|
472
|
+
"""Apply 10-band equalizer preset using lavfi."""
|
|
473
|
+
af_str = self._get_eq_af_string(preset_name)
|
|
474
|
+
self.set_property("af", af_str)
|
|
475
|
+
|
|
407
476
|
# === [TUI Application] ===
|
|
408
477
|
class MyTunesApp:
|
|
409
478
|
def __init__(self, stdscr):
|
|
@@ -420,12 +489,19 @@ class MyTunesApp:
|
|
|
420
489
|
self.scroll_offset = 0
|
|
421
490
|
self.current_track = None
|
|
422
491
|
self.cached_history = [] # Snapshot for stable history view
|
|
423
|
-
self.status_msg = ""
|
|
492
|
+
self.status_msg = "" # Deprecated, keeping for safety until full refactor
|
|
493
|
+
self.feedback_msg = ""
|
|
494
|
+
self.feedback_expiry = 0
|
|
495
|
+
self.view_msg = ""
|
|
424
496
|
|
|
425
497
|
# Queue System
|
|
426
498
|
self.queue = []
|
|
427
499
|
self.queue_idx = -1
|
|
428
500
|
|
|
501
|
+
# Audio EQ
|
|
502
|
+
self.current_eq_index = 0 # Default to 0 -> Flat
|
|
503
|
+
self.eq_overlay_time = 0 # For showing EQ change status temporarily
|
|
504
|
+
|
|
429
505
|
# Search State
|
|
430
506
|
self.current_search_query = None
|
|
431
507
|
# self.search_page = 1 # Deprecated: Pagination Removed v2.0.2
|
|
@@ -437,7 +513,8 @@ class MyTunesApp:
|
|
|
437
513
|
self.playback_duration = 0
|
|
438
514
|
self.is_paused = False
|
|
439
515
|
self.last_save_time = time.time()
|
|
440
|
-
self.
|
|
516
|
+
self.status_set_time = 0
|
|
517
|
+
self.auto_preset_name = "Pop" # Default Auto detected genre
|
|
441
518
|
|
|
442
519
|
# Throttling Counters
|
|
443
520
|
self.loop_count = 0
|
|
@@ -470,6 +547,17 @@ class MyTunesApp:
|
|
|
470
547
|
self.sent_history = {}
|
|
471
548
|
|
|
472
549
|
|
|
550
|
+
def show_feedback(self, msg, duration=2.5):
|
|
551
|
+
"""Show transient feedback (keys/errors) that overrides view status temporarily."""
|
|
552
|
+
self.feedback_msg = msg
|
|
553
|
+
self.feedback_expiry = time.time() + duration
|
|
554
|
+
self.draw() # Force immediate update
|
|
555
|
+
|
|
556
|
+
def set_view_status(self, msg):
|
|
557
|
+
"""Set persistent status specific to the current view (e.g., Favorites path)."""
|
|
558
|
+
self.view_msg = msg
|
|
559
|
+
# We don't force draw here usually, the loop handles it, or caller does.
|
|
560
|
+
|
|
473
561
|
def handle_disconnect(self, signum, frame):
|
|
474
562
|
"""Auto-background if terminal disconnects."""
|
|
475
563
|
self.stop_on_exit = False
|
|
@@ -548,7 +636,7 @@ class MyTunesApp:
|
|
|
548
636
|
now = time.time()
|
|
549
637
|
if self.player.loading and (now - self.player.loading_ts > 8):
|
|
550
638
|
self.player.loading = False
|
|
551
|
-
self.
|
|
639
|
+
self.show_feedback("⚠️ Load timed out. Skipping...")
|
|
552
640
|
|
|
553
641
|
# 2. Frequent: Pause state (Every 2 loops ~400ms)
|
|
554
642
|
if self.loop_count % 2 == 0:
|
|
@@ -644,27 +732,35 @@ class MyTunesApp:
|
|
|
644
732
|
"<": "SEEK_BACK_30", ">": "SEEK_FWD_30",
|
|
645
733
|
"a": "TOGGLE_FAV", "5": "TOGGLE_FAV",
|
|
646
734
|
str(curses.KEY_F7): "OPEN_BROWSER",
|
|
647
|
-
str(curses.KEY_DC): "DELETE", "d": "DELETE"
|
|
735
|
+
str(curses.KEY_DC): "DELETE", "d": "DELETE",
|
|
736
|
+
"e": "CYCLE_EQ", "E": "CYCLE_EQ"
|
|
648
737
|
}
|
|
649
|
-
|
|
738
|
+
cmd = mapping.get(k_char)
|
|
739
|
+
if cmd: return cmd
|
|
740
|
+
return ("UNKNOWN", key)
|
|
650
741
|
|
|
651
742
|
def handle_input(self):
|
|
652
743
|
"""Clean dispatcher: Get normalized command and execute it."""
|
|
653
744
|
cmd = self.get_next_event()
|
|
654
745
|
if not cmd: return
|
|
655
746
|
|
|
747
|
+
# Reset transient state for valid commands
|
|
748
|
+
is_transient = any(kw in self.status_msg for kw in ["Invalid key", "잘못된 키", "영문", "English"])
|
|
749
|
+
if is_transient:
|
|
750
|
+
self.status_msg = ""
|
|
751
|
+
|
|
656
752
|
current_list = self.get_current_list()
|
|
657
753
|
|
|
658
754
|
# 1. Functional Commands (Require Logic)
|
|
659
755
|
if cmd == "NAV_BACK":
|
|
660
756
|
if len(self.view_stack) > 1:
|
|
661
757
|
self.forward_stack.append(self.view_stack.pop())
|
|
662
|
-
self.selection_idx = 0; self.scroll_offset = 0; self.
|
|
758
|
+
self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
|
|
663
759
|
|
|
664
760
|
elif cmd == "NAV_FORWARD":
|
|
665
761
|
if self.forward_stack:
|
|
666
762
|
self.view_stack.append(self.forward_stack.pop())
|
|
667
|
-
self.selection_idx = 0; self.scroll_offset = 0; self.
|
|
763
|
+
self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
|
|
668
764
|
|
|
669
765
|
elif cmd == "MOVE_UP":
|
|
670
766
|
if self.selection_idx > 0:
|
|
@@ -695,31 +791,31 @@ class MyTunesApp:
|
|
|
695
791
|
elif cmd == "FAVORITES":
|
|
696
792
|
if self.view_stack[-1] != "favorites":
|
|
697
793
|
self.forward_stack = []; self.view_stack.append("favorites"); self.selection_idx = 0
|
|
698
|
-
self.
|
|
794
|
+
self.set_view_status(self.t("favorites_info", DATA_FILE))
|
|
699
795
|
|
|
700
796
|
elif cmd == "HISTORY":
|
|
701
797
|
if self.view_stack[-1] != "history":
|
|
702
798
|
self.forward_stack = []; self.cached_history = list(self.dm.data['history'])
|
|
703
799
|
self.view_stack.append("history"); self.selection_idx = 0
|
|
704
|
-
self.
|
|
800
|
+
self.set_view_status(self.t("hist_info"))
|
|
705
801
|
|
|
706
802
|
elif cmd == "MAIN_MENU":
|
|
707
|
-
self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.
|
|
803
|
+
self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
|
|
708
804
|
|
|
709
805
|
elif cmd == "TOGGLE_PAUSE": self.player.toggle_pause()
|
|
710
|
-
elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.
|
|
711
|
-
elif cmd == "VOL_UP": self.player.change_volume(5); self.
|
|
806
|
+
elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.show_feedback("Volume -5")
|
|
807
|
+
elif cmd == "VOL_UP": self.player.change_volume(5); self.show_feedback("Volume +5")
|
|
712
808
|
elif cmd == "SEEK_BACK_10": self.player.seek(-10)
|
|
713
809
|
elif cmd == "SEEK_FWD_10": self.player.seek(10)
|
|
714
|
-
elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.
|
|
715
|
-
elif cmd == "SEEK_FWD_30": self.player.seek(30); self.
|
|
810
|
+
elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.show_feedback("Rewind 30s")
|
|
811
|
+
elif cmd == "SEEK_FWD_30": self.player.seek(30); self.show_feedback("Forward 30s")
|
|
716
812
|
|
|
717
813
|
elif cmd == "TOGGLE_FAV":
|
|
718
814
|
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
719
815
|
target = current_list[self.selection_idx]
|
|
720
816
|
if "url" in target:
|
|
721
817
|
is_added = self.dm.toggle_favorite(target)
|
|
722
|
-
self.
|
|
818
|
+
self.show_feedback(self.t("fav_added") if is_added else self.t("fav_removed"))
|
|
723
819
|
|
|
724
820
|
elif cmd == "DELETE":
|
|
725
821
|
self.handle_deletion(current_list)
|
|
@@ -745,6 +841,20 @@ class MyTunesApp:
|
|
|
745
841
|
elif cmd == "EXIT_BKG":
|
|
746
842
|
self.stop_on_exit = False; self.running = False
|
|
747
843
|
|
|
844
|
+
elif cmd == "CYCLE_EQ":
|
|
845
|
+
self.cycle_equalizer()
|
|
846
|
+
|
|
847
|
+
elif isinstance(cmd, tuple) and cmd[0] == "UNKNOWN":
|
|
848
|
+
key = cmd[1]
|
|
849
|
+
if isinstance(key, str) and ord(key[0]) > 127:
|
|
850
|
+
self.show_feedback(self.t("ime_warning"))
|
|
851
|
+
self.status_set_time = time.time()
|
|
852
|
+
# self.draw() # Handled by show_feedback
|
|
853
|
+
elif isinstance(key, str) and key.isprintable():
|
|
854
|
+
self.show_feedback(self.t("invalid_key", key))
|
|
855
|
+
self.status_set_time = time.time()
|
|
856
|
+
# self.draw() # Handled by show_feedback
|
|
857
|
+
|
|
748
858
|
def handle_deletion(self, current_list):
|
|
749
859
|
"""Sub-logic for DELETE command to keep dispatcher clean."""
|
|
750
860
|
if not current_list or not (0 <= self.selection_idx < len(current_list)): return
|
|
@@ -753,23 +863,277 @@ class MyTunesApp:
|
|
|
753
863
|
success = False
|
|
754
864
|
if view == "favorites":
|
|
755
865
|
success = self.dm.remove_favorite_by_index(self.selection_idx)
|
|
756
|
-
if success: self.
|
|
866
|
+
if success: self.show_feedback("🗑️ Deleted from Favorites")
|
|
757
867
|
elif view == "history":
|
|
758
868
|
success = self.dm.remove_history_by_index(self.selection_idx)
|
|
759
|
-
if success: self.cached_history = list(self.dm.data['history']); self.
|
|
869
|
+
if success: self.cached_history = list(self.dm.data['history']); self.show_feedback("🗑️ Deleted from History")
|
|
760
870
|
elif view == "search":
|
|
761
871
|
if self.current_search_query is None:
|
|
762
872
|
success = self.dm.remove_search_history_by_index(self.selection_idx)
|
|
763
|
-
if success: self.search_results = self.dm.get_search_history(); self.
|
|
873
|
+
if success: self.search_results = self.dm.get_search_history(); self.show_feedback("🗑️ Deleted from Search History")
|
|
764
874
|
else:
|
|
765
|
-
try: self.search_results.pop(self.selection_idx); success = True; self.
|
|
875
|
+
try: self.search_results.pop(self.selection_idx); success = True; self.show_feedback("Removed from list")
|
|
766
876
|
except: pass
|
|
767
|
-
if success:
|
|
768
|
-
self.selection_idx = max(0, min(self.selection_idx, len(self.get_current_list()) - 1))
|
|
769
877
|
|
|
878
|
+
def cycle_equalizer(self):
|
|
879
|
+
self.current_eq_index = (self.current_eq_index + 1) % len(EQUALIZER_KEYS)
|
|
880
|
+
new_preset = EQUALIZER_KEYS[self.current_eq_index]
|
|
881
|
+
|
|
882
|
+
if new_preset == "Auto":
|
|
883
|
+
# Immediate trigger for current track
|
|
884
|
+
auto_preset = self.detect_auto_eq(self.current_track) if self.current_track else "Pop"
|
|
885
|
+
self.player.set_equalizer(auto_preset)
|
|
886
|
+
self.show_feedback(f"🎚 Auto: {auto_preset}") # Explicit feedback on change
|
|
887
|
+
else:
|
|
888
|
+
self.player.set_equalizer(new_preset)
|
|
889
|
+
self.show_feedback(f"🎚 EQ: {new_preset}") # Explicit feedback on change
|
|
890
|
+
|
|
891
|
+
self.status_set_time = time.time()
|
|
892
|
+
|
|
893
|
+
def detect_auto_eq(self, item):
|
|
894
|
+
"""
|
|
895
|
+
Smart genre detection using weighted scoring.
|
|
896
|
+
Analyzes Title and Author for keywords.
|
|
897
|
+
"""
|
|
898
|
+
# Prepare texts
|
|
899
|
+
title_text = ""
|
|
900
|
+
extra_text = ""
|
|
901
|
+
|
|
902
|
+
if isinstance(item, str):
|
|
903
|
+
title_text = item.lower()
|
|
904
|
+
else:
|
|
905
|
+
title_text = item.get('title', '').lower()
|
|
906
|
+
# extra_text removed to strictly follow title-based detection
|
|
907
|
+
|
|
908
|
+
# DEBUG: Log detection attempt
|
|
909
|
+
# (Debug code removed)
|
|
910
|
+
|
|
911
|
+
scores = {k: 0 for k in EQUALIZER_PRESETS.keys() if k != "Flat"}
|
|
912
|
+
|
|
913
|
+
# Multilingual Genre Keywords (En, Ko, Ja, Zh, Ru, Es, Fr, Vi)
|
|
914
|
+
# Priorities specific genre words over artist names for accuracy.
|
|
915
|
+
rules = [
|
|
916
|
+
("Rock", [
|
|
917
|
+
"rock", "metal", "grunge", "punk", "band", "guitar solo", "drum",
|
|
918
|
+
"락", "록", "메탈", "밴드", "기타", # Ko
|
|
919
|
+
"ロック", "メタル", "パンク", "バンド", # Ja
|
|
920
|
+
"摇滚", "金属乐", "乐队", # Zh
|
|
921
|
+
"рок", "metal", "панк", "группа", # Ru
|
|
922
|
+
"roca", "metal", "punk", "banda", # Es
|
|
923
|
+
"rocher", "métal", "groupe", # Fr
|
|
924
|
+
"nhạc rock", "ban nhạc", # Vi
|
|
925
|
+
"roque", "metal", "banda", "guitarra", # Pt
|
|
926
|
+
"rock", "metall", "band", "gitarre", # De
|
|
927
|
+
"rock", "band", # Hi
|
|
928
|
+
"ร็อค", "วง", "กีตาร์", # Th
|
|
929
|
+
"queen", "ac/dc", "nirvana", "linkin park", "oasis", "coldplay" # Iconic Fallbacks
|
|
930
|
+
]),
|
|
931
|
+
("Jazz", [
|
|
932
|
+
"jazz", "blues", "piano", "saxophone", "trumpet", "cafe", "coffee", "lounge", "smooth", "relaxing", "dinner", "wine", "bar", "mood",
|
|
933
|
+
"재즈", "블루스", "피아노", "카페", "커피", "라운지", "무드", # Ko
|
|
934
|
+
"ジャズ", "ブルース", "ピアノ", "カフェ", "ラウンジ", # Ja
|
|
935
|
+
"爵士", "蓝调", "钢琴", "咖啡", # Zh
|
|
936
|
+
"джаз", "блюз", "пианино", "кафе", "лаунж", # Ru
|
|
937
|
+
"jazz", "blues", "piano", "café", "salón", # Es
|
|
938
|
+
"jazz", "blues", "piano", "café", "salon", # Fr
|
|
939
|
+
"nhạc jazz", "nhạc blues", "dương cầm", "cà phê", # Vi
|
|
940
|
+
"jazz", "blues", "piano", "bossa nova", "samba", "café", # Pt
|
|
941
|
+
"jazz", "blues", "klavier", "kaffee", # De
|
|
942
|
+
"jazz", "piano", # Hi
|
|
943
|
+
"แจ๊ส", "เปียโน", "กาแฟ", # Th
|
|
944
|
+
"norah jones", "chet baker", "bill evans"
|
|
945
|
+
]),
|
|
946
|
+
("Classical", [
|
|
947
|
+
"classical", "classic", "orchestra", "symphony", "concerto", "sonata", "violin", "cello", "opera", "choir", "philharmonic",
|
|
948
|
+
"클래식", "오케스트라", "교향곡", "협주곡", "소나타", "바이올린", "첼로", "오페라", "합창", # Ko
|
|
949
|
+
"クラシック", "オーケストラ", "交響曲", "協奏曲", "ソナタ", "バイオリン", "チェロ", # Ja
|
|
950
|
+
"古典", "交响乐", "协奏曲", "奏鸣曲", "小提琴", "大提琴", # Zh
|
|
951
|
+
"классика", "оркестр", "симфония", "концерт", "соната", "скрипка", "виолончель", # Ru
|
|
952
|
+
"clásica", "orquesta", "sinfonía", "concierto", # Es
|
|
953
|
+
"classique", "orchestre", "symphonie", "concerto", # Fr
|
|
954
|
+
"cổ điển", "dàn nhạc", "giao hưởng", # Vi
|
|
955
|
+
"clássica", "orquestra", "sinfonia", "piano", # Pt
|
|
956
|
+
"klassik", "orchester", "sinfonie", "klavier", # De
|
|
957
|
+
"classical", "orchestra", # Hi
|
|
958
|
+
"คลาสสิก", "ออเคสตรา", "เปียโน", # Th
|
|
959
|
+
"mozart", "bach", "beethoven", "chopin", "disney", "ghibli"
|
|
960
|
+
]),
|
|
961
|
+
("Club", [
|
|
962
|
+
"edm", "club", "dance floor", "remix", "mix", "dj", "techno", "house", "trance", "dubstep", "bass boost", "electronic",
|
|
963
|
+
"클럽", "리믹스", "믹스", "디제이", "테크노", "하우스", "일렉", "이디엠", # Ko
|
|
964
|
+
"クラブ", "リミックス", "テクノ", "ハウス", "エレクトロニック", # Ja
|
|
965
|
+
"俱乐部", "混音", "电音", "电子", # Zh
|
|
966
|
+
"клуб", "ремикс", "диджей", "техно", "хаус", "электроника", # Ru
|
|
967
|
+
"club", "remix", "mezcla", "electrónica", # Es
|
|
968
|
+
"club", "remix", "mélange", "électronique", # Fr
|
|
969
|
+
"câu lạc bộ", "phối lại", "điện tử", "nhạc sàn", # Vi
|
|
970
|
+
"clube", "remix", "eletrônica", "balada", # Pt
|
|
971
|
+
"club", "remix", "elektronisch", "techno", "nacht", # De
|
|
972
|
+
"club", "remix", "dj", # Hi
|
|
973
|
+
"คลับ", "รีมิกซ์", "ดีเจ", "แดนซ์" # Th
|
|
974
|
+
]),
|
|
975
|
+
("Dance", [
|
|
976
|
+
"dance", "disco", "party", "choreography", "upbeat", "idol", "kpop", "k-pop", "j-pop", "pop dance", "tango", "salsa", "swing",
|
|
977
|
+
"댄스", "디스코", "파티", "안무", "아이돌", "케이팝", "신나는", "탱고", "살사", # Ko
|
|
978
|
+
"ダンス", "ディスコ", "パーティー", "アイドル", "タンゴ", "サルサ", # Ja
|
|
979
|
+
"舞曲", "迪斯科", "派对", "偶像", "探戈", "莎莎", # Zh
|
|
980
|
+
"танец", "диско", "вечеринка", "айдол", "танго", # Ru
|
|
981
|
+
"baile", "disco", "fiesta", "íbodo", "tango", "salsa", # Es
|
|
982
|
+
"danse", "discothèque", "fête", "tango", "salsa", # Fr
|
|
983
|
+
"nhảy", "khiêu vũ", "tiệc", "thần tượng", "tango", # Vi
|
|
984
|
+
"dança", "festa", "funk", "ídolo", # Pt
|
|
985
|
+
"tanz", "party", "schlager", # De
|
|
986
|
+
"dance", "party", "bollywood", "nach", # Hi
|
|
987
|
+
"เต้น", "ปาร์ตี้", "ไอดอล" # Th
|
|
988
|
+
]),
|
|
989
|
+
("Full Bass", [
|
|
990
|
+
"hip hop", "hiphop", "rap", "r&b", "soul", "trap", "beat", "bass", "boom bap", "drill", "grime",
|
|
991
|
+
"힙합", "랩", "알앤비", "소울", "트랩", "비트", "베이스", "쇼미더머니", # Ko
|
|
992
|
+
"ヒップホップ", "ラップ", "ソウル", "トラップ", "ベース", # Ja
|
|
993
|
+
"嘻哈", "说唱", "饶舌", "灵魂乐", "贝斯", # Zh
|
|
994
|
+
"хип-хоп", "рэп", "соул", "трэп", "бас", # Ru
|
|
995
|
+
"hip hop", "rap", "alma", "bajo", # Es
|
|
996
|
+
"hip hop", "rap", "âme", "basse", # Fr
|
|
997
|
+
"hip hop", "rap", "tâm hồn", # Vi
|
|
998
|
+
"hip hop", "rap", "alma", "batida", # Pt
|
|
999
|
+
"hip hop", "rap", "seele", # De
|
|
1000
|
+
"hip hop", "rap", # Hi
|
|
1001
|
+
"ฮิปฮอป", "แร็ป" # Th
|
|
1002
|
+
]),
|
|
1003
|
+
("Live", [
|
|
1004
|
+
"live", "concert", "performance", "stage", "tour", "fancam", "busking", "unplugged", "session",
|
|
1005
|
+
"라이브", "콘서트", "공연", "무대", "투어", "직캠", "버스킹", "실황", # Ko
|
|
1006
|
+
"ライブ", "コンサート", "パフォーマンス", "ステージ", "ツアー", # Ja
|
|
1007
|
+
"现场", "演唱会", "表演", "舞台", "巡演", # Zh
|
|
1008
|
+
"жить", "концерт", "выступление", "сцена", "тур", # Ru
|
|
1009
|
+
"vivo", "concierto", "rendimiento", "escenario", # Es
|
|
1010
|
+
"vivre", "concert", "performance", "scène", # Fr
|
|
1011
|
+
"trực tiếp", "buổi hòa nhạc", "biểu diễn", "sân khấu", # Vi
|
|
1012
|
+
"ao vivo", "concerto", "palco", # Pt
|
|
1013
|
+
"live", "konzert", "bühne", "auftritt", # De
|
|
1014
|
+
"live", "concert", # Hi
|
|
1015
|
+
"สด", "คอนเสิร์ต", "การแสดง" # Th
|
|
1016
|
+
]),
|
|
1017
|
+
("Soft", [
|
|
1018
|
+
"soft", "ballad", "acoustic", "lofi", "lo-fi", "chill", "relax", "sleep", "healing", "study", "reading", "winter", "rain", "snow", "night", "dawn", "morning", "piano", "guitar", "folk", "indie",
|
|
1019
|
+
"소프트", "발라드", "어쿠스틱", "로파이", "칠", "휴식", "자장가", "수면", "힐링", "공부", "독서", "겨울", "비", "눈", "밤", "새벽", "아침", "인디", "포크", "잔잔한", "감성", # Ko
|
|
1020
|
+
"ソフト", "バラード", "アコースティック", "ローファイ", "リラックス", "睡眠", "癒し", "勉強", "冬", "雨", "雪", "夜", "夜明け", # Ja
|
|
1021
|
+
"柔和", "民谣", "原声", "低保真", "放松", "睡眠", "治愈", "学习", "冬", "雨", "雪", "夜", # Zh
|
|
1022
|
+
"мягкий", "баллада", "акустика", "лоу-фай", "расслабляться", "спать", "исцеление", "зима", "дождь", "снег", "ночь", # Ru
|
|
1023
|
+
"suave", "balada", "acústico", "relajarse", "curación", "invierno", "lluvia", "nieve", "noche", # Es
|
|
1024
|
+
"doux", "ballade", "acoustique", "se détendre", "guérison", "hiver", "pluie", "neige", "nuit", # Fr
|
|
1025
|
+
"nhẹ nhàng", "bản ballad", "âm thanh", "thư giãn", "chữa lành", "mùa đông", "mưa", "tuyết", "đêm", # Vi
|
|
1026
|
+
"suave", "balada", "acústico", "relaxar", "sono", # Pt
|
|
1027
|
+
"weich", "ballade", "akustisch", "entspannung", "schlaf", "ruhig", # De
|
|
1028
|
+
"soft", "relax", "sukoon", # Hi
|
|
1029
|
+
"เบาๆ", "บัลลาด", "อะคูสติก", "ผ่อนคลาย", "นอนหลับ" # Th
|
|
1030
|
+
]),
|
|
1031
|
+
("Pop", [
|
|
1032
|
+
"pop", "hits", "best", "top", "chart", "trending", "billboard", "imayo", "kayo", "ost", "soundtrack", "city pop",
|
|
1033
|
+
"팝", "가요", "인기", "히트", "차트", "트렌드", "노래모음", "오에스티", "사운드트랙", "아이돌", "시티팝", "트로트", # Ko
|
|
1034
|
+
"ポップ", "ヒット", "ベスト", "チャート", "トレンド", "サウンドトラック", "シティポップ", "アニメ", # Ja
|
|
1035
|
+
"流行", "热门", "最佳", "榜单", "趋势", "原森", # Zh
|
|
1036
|
+
"поп", "хиты", "лучший", "диаграмма", "тенденция", "саундтрек", # Ru
|
|
1037
|
+
"pop", "éxitos", "mejor", "gráfico", "tendencia", "banda sonora", # Es
|
|
1038
|
+
"pop", "coups", "mieux", "graphique", "tendance", "bande sonore", # Fr
|
|
1039
|
+
"nhạc pop", "lượt truy cập", "tốt nhất", "biểu đồ", "xu hướng", "nhạc phim", # Vi
|
|
1040
|
+
"musica", "pop", "sucesso", "mais tocadas", # Pt
|
|
1041
|
+
"pop", "musik", "hits", "besten", "chart", # De
|
|
1042
|
+
"gana", "geet", "top", "best", # Hi
|
|
1043
|
+
"เพลง", "ป๊อป", "ฮิต", "ยอดนิยม" # Th
|
|
1044
|
+
])
|
|
1045
|
+
]
|
|
770
1046
|
|
|
1047
|
+
# Scoring
|
|
1048
|
+
for genre, keywords in rules:
|
|
1049
|
+
if genre not in scores: continue
|
|
1050
|
+
for k in keywords:
|
|
1051
|
+
# Title Match Only
|
|
1052
|
+
if k in title_text:
|
|
1053
|
+
score = 3
|
|
1054
|
+
# Boost specific keywords even more
|
|
1055
|
+
scores[genre] += score
|
|
1056
|
+
|
|
1057
|
+
# Find winner
|
|
1058
|
+
best_genre = max(scores, key=scores.get)
|
|
1059
|
+
|
|
1060
|
+
if scores[best_genre] > 0:
|
|
1061
|
+
self.auto_preset_name = best_genre
|
|
1062
|
+
return best_genre
|
|
1063
|
+
|
|
1064
|
+
# Default Fallback
|
|
1065
|
+
self.auto_preset_name = "Pop"
|
|
1066
|
+
return "Pop"
|
|
771
1067
|
|
|
1068
|
+
def play_music(self, item, interactive=True, preserve_queue=False):
|
|
1069
|
+
if not item.get("url"): return # Guard against dummy items
|
|
1070
|
+
|
|
1071
|
+
self.current_track = item
|
|
1072
|
+
self.dm.add_history(item)
|
|
1073
|
+
|
|
1074
|
+
# Apply Auto EQ if enabled
|
|
1075
|
+
target_eq_preset = "Flat"
|
|
1076
|
+
current_eq_mode = EQUALIZER_KEYS[self.current_eq_index]
|
|
1077
|
+
|
|
1078
|
+
# Always run detection for debug logging and state freshness
|
|
1079
|
+
self.detect_auto_eq(item) # Updates self.auto_preset_name
|
|
1080
|
+
|
|
1081
|
+
if current_eq_mode == "Auto":
|
|
1082
|
+
target_eq_preset = self.auto_preset_name
|
|
1083
|
+
else:
|
|
1084
|
+
target_eq_preset = current_eq_mode
|
|
772
1085
|
|
|
1086
|
+
self.status_set_time = time.time()
|
|
1087
|
+
|
|
1088
|
+
# Queue Management
|
|
1089
|
+
if not preserve_queue:
|
|
1090
|
+
# New Queue Context from current view
|
|
1091
|
+
current_list = self.get_current_list()
|
|
1092
|
+
# Copy list to queue (Filter only playable items)
|
|
1093
|
+
self.queue = [i for i in current_list if i.get("url")]
|
|
1094
|
+
# Find index in queue
|
|
1095
|
+
try:
|
|
1096
|
+
# Find by URL
|
|
1097
|
+
self.queue_idx = next(i for i, x in enumerate(self.queue) if x['url'] == item['url'])
|
|
1098
|
+
except StopIteration:
|
|
1099
|
+
self.queue_idx = -1
|
|
1100
|
+
self.queue = [] # Should not happen if item came from list
|
|
1101
|
+
|
|
1102
|
+
start_pos = 0
|
|
1103
|
+
if 'url' in item:
|
|
1104
|
+
saved = self.dm.get_progress(item['url'])
|
|
1105
|
+
if saved > 10:
|
|
1106
|
+
# Autoskip resume prompt in Autoplay (interactive=False)
|
|
1107
|
+
if interactive:
|
|
1108
|
+
if self.ask_resume(saved, item.get('title', 'Unknown')): start_pos = saved
|
|
1109
|
+
else:
|
|
1110
|
+
start_pos = 0
|
|
1111
|
+
|
|
1112
|
+
self.player.play(item['url'], start_pos)
|
|
1113
|
+
|
|
1114
|
+
# Re-apply EQ logic (double check: mpv restart wipes af property?)
|
|
1115
|
+
# Yes, play() might restart mpv if socket fails.
|
|
1116
|
+
# But set_equalizer uses set_property which works via IPC *after* launch.
|
|
1117
|
+
# However, play() sends "loadfile" if existing, or restarts.
|
|
1118
|
+
# If restarted, we must re-apply EQ.
|
|
1119
|
+
# Ideally Player.play should handle restoring EQ state or we rely on MyTunesApp to do it.
|
|
1120
|
+
# But for now, let's just re-apply it here after a tiny delay or ensure Player persists it?
|
|
1121
|
+
# Actually Player doesn't persist state. MyTunesApp drives it.
|
|
1122
|
+
# So calling set_equalizer *after* play is correct.
|
|
1123
|
+
|
|
1124
|
+
# Player.play() handles loading the file.
|
|
1125
|
+
# Since we set "af" via IPC AFTER play() starts in some logic,
|
|
1126
|
+
# but here we set it before/during. set_property works anytime.
|
|
1127
|
+
|
|
1128
|
+
# Reset state for new track
|
|
1129
|
+
self.playback_time = start_pos
|
|
1130
|
+
self.playback_duration = 0
|
|
1131
|
+
self.is_paused = False
|
|
1132
|
+
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
1133
|
+
h, w = self.stdscr.getmaxyx()
|
|
1134
|
+
box_h, box_w = 8, 60
|
|
1135
|
+
box_y, box_x = (h - box_h) // 2, (w - box_w) // 2
|
|
1136
|
+
|
|
773
1137
|
def ask_resume(self, saved_time, track_title):
|
|
774
1138
|
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
775
1139
|
h, w = self.stdscr.getmaxyx()
|
|
@@ -842,7 +1206,7 @@ class MyTunesApp:
|
|
|
842
1206
|
|
|
843
1207
|
def open_browser(self, url, app_mode=False):
|
|
844
1208
|
"""Open browser using detached subprocess to prevent TUI freezing."""
|
|
845
|
-
self.
|
|
1209
|
+
self.show_feedback(f"🌐 Opening Link: {url[:30]}...")
|
|
846
1210
|
|
|
847
1211
|
def run_open():
|
|
848
1212
|
try:
|
|
@@ -886,13 +1250,13 @@ class MyTunesApp:
|
|
|
886
1250
|
subprocess.Popen(['xdg-open', url], **popen_kwargs)
|
|
887
1251
|
|
|
888
1252
|
# Feedback logic: Success message then auto-clear
|
|
889
|
-
self.
|
|
1253
|
+
self.show_feedback("✅ Browser Launched! (Check Browser)")
|
|
890
1254
|
time.sleep(2.5)
|
|
891
|
-
if "Launched!" in self.status_msg:
|
|
892
|
-
|
|
1255
|
+
# if "Launched!" in self.status_msg: # Logic changed, feedback auto-clears
|
|
1256
|
+
# self.status_msg = ""
|
|
893
1257
|
except Exception as e:
|
|
894
1258
|
# Log error silently to TUI status
|
|
895
|
-
self.
|
|
1259
|
+
self.show_feedback(f"❌ Browser Error: {str(e)[:20]}")
|
|
896
1260
|
|
|
897
1261
|
# Still execute Popen in a thread to be extra safe,
|
|
898
1262
|
# but Popen itself is now detached and redirected.
|
|
@@ -977,47 +1341,13 @@ class MyTunesApp:
|
|
|
977
1341
|
self.lang = "en" if self.lang == "ko" else "ko"
|
|
978
1342
|
self.dm.data["language"] = self.lang
|
|
979
1343
|
self.dm.save_data()
|
|
980
|
-
self.
|
|
1344
|
+
self.show_feedback("Language Switched / 언어 변경됨") # Clear stale messages on language switch
|
|
981
1345
|
elif item["id"] == "quit": self.running = False
|
|
982
1346
|
else:
|
|
983
1347
|
self.play_music(item, interactive=True)
|
|
984
1348
|
|
|
985
1349
|
|
|
986
|
-
|
|
987
|
-
if not item.get("url"): return # Guard against dummy items
|
|
988
|
-
|
|
989
|
-
self.current_track = item
|
|
990
|
-
self.dm.add_history(item)
|
|
991
|
-
|
|
992
|
-
# Queue Management
|
|
993
|
-
if not preserve_queue:
|
|
994
|
-
# New Queue Context from current view
|
|
995
|
-
current_list = self.get_current_list()
|
|
996
|
-
# Copy list to queue (Filter only playable items)
|
|
997
|
-
self.queue = [i for i in current_list if i.get("url")]
|
|
998
|
-
# Find index in queue
|
|
999
|
-
try:
|
|
1000
|
-
# Find by URL
|
|
1001
|
-
self.queue_idx = next(i for i, x in enumerate(self.queue) if x['url'] == item['url'])
|
|
1002
|
-
except StopIteration:
|
|
1003
|
-
self.queue_idx = -1
|
|
1004
|
-
self.queue = [] # Should not happen if item came from list
|
|
1005
|
-
|
|
1006
|
-
start_pos = 0
|
|
1007
|
-
if 'url' in item:
|
|
1008
|
-
saved = self.dm.get_progress(item['url'])
|
|
1009
|
-
if saved > 10:
|
|
1010
|
-
# Autoskip resume prompt in Autoplay (interactive=False)
|
|
1011
|
-
if interactive:
|
|
1012
|
-
if self.ask_resume(saved, item.get('title', 'Unknown')): start_pos = saved
|
|
1013
|
-
else:
|
|
1014
|
-
start_pos = 0
|
|
1015
|
-
|
|
1016
|
-
self.player.play(item['url'], start_pos)
|
|
1017
|
-
# Reset state for new track
|
|
1018
|
-
self.playback_time = start_pos
|
|
1019
|
-
self.playback_duration = 0
|
|
1020
|
-
self.is_paused = False
|
|
1350
|
+
|
|
1021
1351
|
|
|
1022
1352
|
def input_dialog(self, title, prompt):
|
|
1023
1353
|
"""Show a centered input dialog with robust byte-level handling (Fixes Double Enter)."""
|
|
@@ -1123,7 +1453,7 @@ class MyTunesApp:
|
|
|
1123
1453
|
self.scroll_offset = 0
|
|
1124
1454
|
if self.view_stack[-1] != "search":
|
|
1125
1455
|
self.view_stack.append("search")
|
|
1126
|
-
self.
|
|
1456
|
+
self.set_view_status("") # Clear "List is empty" etc.
|
|
1127
1457
|
self.draw()
|
|
1128
1458
|
|
|
1129
1459
|
query = self.input_dialog(self.t("search_label"), self.t("search_prompt"))
|
|
@@ -1135,7 +1465,7 @@ class MyTunesApp:
|
|
|
1135
1465
|
# I should check if it's possible to distinguish.
|
|
1136
1466
|
|
|
1137
1467
|
if query:
|
|
1138
|
-
self.
|
|
1468
|
+
self.show_feedback(self.t("searching"))
|
|
1139
1469
|
self.draw()
|
|
1140
1470
|
# v2.0.0 Refactor: Threaded Search
|
|
1141
1471
|
threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
|
|
@@ -1154,7 +1484,7 @@ class MyTunesApp:
|
|
|
1154
1484
|
# self.player.loading = True
|
|
1155
1485
|
|
|
1156
1486
|
self.current_search_query = query
|
|
1157
|
-
self.
|
|
1487
|
+
self.set_view_status(self.t("searching")) # Persist while threading? Or feedback? Use View Status for async wait
|
|
1158
1488
|
|
|
1159
1489
|
# Resolve yt-dlp path
|
|
1160
1490
|
yt_dlp_cmd = "yt-dlp"
|
|
@@ -1190,7 +1520,15 @@ class MyTunesApp:
|
|
|
1190
1520
|
# Dedup Check
|
|
1191
1521
|
if url not in seen_urls:
|
|
1192
1522
|
seen_urls.add(url)
|
|
1193
|
-
|
|
1523
|
+
# Extract Channel/Author
|
|
1524
|
+
channel = d.get("uploader") or d.get("channel") or d.get("uploader_id") or "Unknown"
|
|
1525
|
+
new.append({
|
|
1526
|
+
"title": d.get("title", "Unknown"),
|
|
1527
|
+
"url": url,
|
|
1528
|
+
"duration": dur_str,
|
|
1529
|
+
"author": channel,
|
|
1530
|
+
"channel": channel
|
|
1531
|
+
})
|
|
1194
1532
|
except: pass
|
|
1195
1533
|
|
|
1196
1534
|
# Enforce hard limit
|
|
@@ -1205,11 +1543,11 @@ class MyTunesApp:
|
|
|
1205
1543
|
# SAVE to History
|
|
1206
1544
|
self.dm.add_search_results(new)
|
|
1207
1545
|
|
|
1208
|
-
self.
|
|
1546
|
+
self.set_view_status(f"Search Done. ({len(new)} results)")
|
|
1209
1547
|
else:
|
|
1210
|
-
self.
|
|
1548
|
+
self.set_view_status(self.t("no_results"))
|
|
1211
1549
|
|
|
1212
|
-
except Exception as e: self.
|
|
1550
|
+
except Exception as e: self.show_feedback(f"Error: {e}")
|
|
1213
1551
|
finally:
|
|
1214
1552
|
self.player.loading = False
|
|
1215
1553
|
|
|
@@ -1259,11 +1597,47 @@ class MyTunesApp:
|
|
|
1259
1597
|
bar_str = f"[{'='*fill_w}{'-'*(bar_w-fill_w)}] {time_str}"
|
|
1260
1598
|
self.stdscr.addstr(h - 4, 2, bar_str, curses.color_pair(3))
|
|
1261
1599
|
|
|
1262
|
-
# Footer Line 2: Song Title
|
|
1600
|
+
# Footer Line 2: Song Title + EQ Status (Right Aligned)
|
|
1263
1601
|
if self.current_track:
|
|
1264
1602
|
status_icon = "❚❚" if self.is_paused else "▶"
|
|
1265
|
-
|
|
1266
|
-
|
|
1603
|
+
|
|
1604
|
+
# Prepare EQ Info for Title Line
|
|
1605
|
+
current_eq = EQUALIZER_KEYS[self.current_eq_index]
|
|
1606
|
+
|
|
1607
|
+
# Show effective EQ with Mode Indicator
|
|
1608
|
+
# Auto Mode: "🎚 Auto: Jazz"
|
|
1609
|
+
# Manual Mode: "🎚 EQ: Pop"
|
|
1610
|
+
if current_eq == "Auto":
|
|
1611
|
+
eq_display = f"🎚 Auto: {self.auto_preset_name}"
|
|
1612
|
+
else:
|
|
1613
|
+
eq_display = f"🎚 EQ: {current_eq}"
|
|
1614
|
+
|
|
1615
|
+
eq_info = f" [{eq_display}]" # Right side content
|
|
1616
|
+
|
|
1617
|
+
# Calculate space for Title
|
|
1618
|
+
# Total Width - margins(2) - Icon(2) - Space(1) - EQ Info - Branding(Maybe separate line, but here we just need fit)
|
|
1619
|
+
# Actually Title line is separate from Branding line.
|
|
1620
|
+
# w - 2 (left) - 2 (icon) - len(eq_info)
|
|
1621
|
+
|
|
1622
|
+
avail_w = w - 6 - self.get_display_width(eq_info)
|
|
1623
|
+
|
|
1624
|
+
song_title = self.current_track['title']
|
|
1625
|
+
# Append Channel check? May make title too long, prioritize Title + EQ
|
|
1626
|
+
ch_name = self.current_track.get('channel') or self.current_track.get('author')
|
|
1627
|
+
if ch_name:
|
|
1628
|
+
song_title += f" [{ch_name}]" # Append content first
|
|
1629
|
+
|
|
1630
|
+
trunc_title = self.truncate(song_title, avail_w)
|
|
1631
|
+
|
|
1632
|
+
# Draw Title
|
|
1633
|
+
self.stdscr.addstr(h - 3, 2, f"{status_icon} {trunc_title}", curses.color_pair(2))
|
|
1634
|
+
|
|
1635
|
+
# Draw EQ Info (Right Aligned on same line)
|
|
1636
|
+
eq_x = 2 + self.get_display_width(f"{status_icon} {trunc_title}")
|
|
1637
|
+
# Or strictly right aligned?
|
|
1638
|
+
eq_x_strict = w - 2 - self.get_display_width(eq_info)
|
|
1639
|
+
self.stdscr.addstr(h - 3, eq_x_strict, eq_info, curses.color_pair(3) | curses.A_BOLD)
|
|
1640
|
+
|
|
1267
1641
|
else:
|
|
1268
1642
|
self.stdscr.addstr(h - 3, 2, self.t("stopped"), curses.color_pair(1))
|
|
1269
1643
|
|
|
@@ -1274,16 +1648,31 @@ class MyTunesApp:
|
|
|
1274
1648
|
# Draw Branding always - Bright/Bold White
|
|
1275
1649
|
self.stdscr.addstr(h - 2, branding_x, branding, curses.color_pair(1) | curses.A_BOLD)
|
|
1276
1650
|
|
|
1277
|
-
# Draw Status Msg
|
|
1651
|
+
# Draw Status Msg (Priority Logic)
|
|
1652
|
+
displayed_msg = ""
|
|
1653
|
+
attr = curses.A_NORMAL
|
|
1654
|
+
|
|
1655
|
+
# 1. Loading
|
|
1278
1656
|
if self.player.loading:
|
|
1279
|
-
|
|
1280
|
-
|
|
1657
|
+
displayed_msg = "⏳ Loading..."
|
|
1658
|
+
attr = curses.color_pair(6) | curses.A_BLINK
|
|
1659
|
+
|
|
1660
|
+
# 2. Transient Feedback (Volume, Keypress, Errors)
|
|
1661
|
+
elif time.time() < self.feedback_expiry and self.feedback_msg:
|
|
1662
|
+
displayed_msg = f"📢 {self.feedback_msg}"
|
|
1663
|
+
attr = curses.color_pair(3) | curses.A_BOLD
|
|
1664
|
+
|
|
1665
|
+
# 3. Persistent View Context (Favorites path, History count, etc.)
|
|
1666
|
+
elif self.view_msg:
|
|
1667
|
+
displayed_msg = f"ℹ️ {self.view_msg}"
|
|
1668
|
+
attr = curses.color_pair(1)
|
|
1669
|
+
|
|
1670
|
+
# 4. Fallback/Idle (Empty)
|
|
1671
|
+
|
|
1672
|
+
if displayed_msg:
|
|
1281
1673
|
avail_w = branding_x - 4
|
|
1282
1674
|
if avail_w > 5:
|
|
1283
|
-
|
|
1284
|
-
attr = curses.color_pair(6)
|
|
1285
|
-
if self.status_blink: attr |= curses.A_BLINK | curses.A_BOLD
|
|
1286
|
-
self.stdscr.addstr(h - 2, 2, f"📢 {msg}", attr)
|
|
1675
|
+
self.stdscr.addstr(h - 2, 2, self.truncate(displayed_msg, avail_w), attr)
|
|
1287
1676
|
|
|
1288
1677
|
# List Area (Remaining Middle)
|
|
1289
1678
|
list_top = 4
|
|
@@ -1323,6 +1712,25 @@ class MyTunesApp:
|
|
|
1323
1712
|
|
|
1324
1713
|
title_txt = self.truncate(item.get('title',''), avail_w)
|
|
1325
1714
|
|
|
1715
|
+
# Channel Name Logic (Smart Display)
|
|
1716
|
+
channel_txt = ""
|
|
1717
|
+
box_w = 0
|
|
1718
|
+
if w > 80: # Only show if screen is wide enough
|
|
1719
|
+
raw_ch = item.get('channel') or item.get('author') or ""
|
|
1720
|
+
if raw_ch:
|
|
1721
|
+
# Max channel width: 20 or 25% of width
|
|
1722
|
+
max_ch_w = min(20, w // 4)
|
|
1723
|
+
if len(raw_ch) > max_ch_w:
|
|
1724
|
+
raw_ch = raw_ch[:max_ch_w-1] + "…"
|
|
1725
|
+
channel_txt = f"[{raw_ch}]"
|
|
1726
|
+
box_w = len(channel_txt) + 1 # +1 padding
|
|
1727
|
+
|
|
1728
|
+
# Refine Title Width based on Channel presence
|
|
1729
|
+
real_avail_w = avail_w - box_w
|
|
1730
|
+
if real_avail_w < 5: real_avail_w = 5
|
|
1731
|
+
|
|
1732
|
+
title_txt = self.truncate(item.get('title',''), real_avail_w)
|
|
1733
|
+
|
|
1326
1734
|
try:
|
|
1327
1735
|
curr_x = 2
|
|
1328
1736
|
# Base Style
|
|
@@ -1334,17 +1742,15 @@ class MyTunesApp:
|
|
|
1334
1742
|
base_style = curses.A_NORMAL
|
|
1335
1743
|
|
|
1336
1744
|
# 1. Prefix
|
|
1337
|
-
# If selected, base_style is Blue/White. If playing(unselected), Green.
|
|
1338
1745
|
self.stdscr.addstr(y_pos, curr_x, prefix, base_style)
|
|
1339
1746
|
curr_x += len(prefix)
|
|
1340
1747
|
|
|
1341
|
-
# 2. Play Icon
|
|
1342
|
-
# base_style already covers Green if playing and not selected.
|
|
1748
|
+
# 2. Play Icon
|
|
1343
1749
|
if chk_icon:
|
|
1344
1750
|
self.stdscr.addstr(y_pos, curr_x, chk_icon, base_style)
|
|
1345
1751
|
curr_x += len(chk_icon)
|
|
1346
1752
|
|
|
1347
|
-
# 3. Fav Icon
|
|
1753
|
+
# 3. Fav Icon
|
|
1348
1754
|
f_style = base_style
|
|
1349
1755
|
if fav_icon and not is_sel: f_style = curses.color_pair(3) | curses.A_BOLD
|
|
1350
1756
|
if fav_icon:
|
|
@@ -1355,15 +1761,15 @@ class MyTunesApp:
|
|
|
1355
1761
|
self.stdscr.addstr(y_pos, curr_x, title_txt, base_style)
|
|
1356
1762
|
curr_x += self.get_display_width(title_txt)
|
|
1357
1763
|
|
|
1358
|
-
# 5.
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
# 6. Duration
|
|
1764
|
+
# 5. Channel Info (Right aligned relative to title space or fixed?)
|
|
1765
|
+
if channel_txt:
|
|
1766
|
+
self.stdscr.addstr(y_pos, curr_x + 1, channel_txt, base_style | curses.A_DIM)
|
|
1767
|
+
# No update to curr_x needed for duration anchor, but good for safety
|
|
1768
|
+
|
|
1769
|
+
# 6. Duration (Right Anchored)
|
|
1365
1770
|
if dur_txt:
|
|
1366
|
-
|
|
1771
|
+
dur_x = w - 2 - len(dur_txt)
|
|
1772
|
+
self.stdscr.addstr(y_pos, dur_x, dur_txt, base_style)
|
|
1367
1773
|
|
|
1368
1774
|
except: pass
|
|
1369
1775
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mytunes-pro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.6
|
|
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
|
|
@@ -13,18 +13,19 @@ Requires-Python: >=3.9
|
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
15
|
Requires-Dist: requests
|
|
16
|
+
Requires-Dist: urllib3<2.0.0
|
|
16
17
|
Requires-Dist: yt-dlp
|
|
17
18
|
Requires-Dist: pusher
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
|
-
# 🎵 MyTunes Pro - Professional TUI Edition v2.0.
|
|
21
|
+
# 🎵 MyTunes Pro - Professional TUI Edition v2.0.5
|
|
21
22
|
|
|
22
|
-
## 🚀 Terminal-based Media Workflow Experiment v2.0.
|
|
23
|
+
## 🚀 Terminal-based Media Workflow Experiment v2.0.5
|
|
23
24
|
|
|
24
25
|
> [!IMPORTANT]
|
|
25
|
-
> **Legal Disclaimer:** This project is a personal, non-commercial research experiment for developer education.
|
|
26
|
-
> It does not host, provide, or distribute any media content.
|
|
27
|
-
> All media sources are independently accessed and configured by the user.
|
|
26
|
+
> **Legal Disclaimer:** This project is a personal, non-commercial research experiment for developer education.
|
|
27
|
+
> It does not host, provide, or distribute any media content.
|
|
28
|
+
> All media sources are independently accessed and configured by the user.
|
|
28
29
|
> Users are solely responsible for ensuring that their usage complies with the terms of service of any third-party platforms accessed via this tool.
|
|
29
30
|
|
|
30
31
|
MyTunes Pro is a developer-focused **CLI Media Tool** for experimenting with terminal-based media workflows.
|
|
@@ -38,6 +39,8 @@ leveraging the `mpv` engine for local media processing and playback.
|
|
|
38
39
|

|
|
39
40
|

|
|
40
41
|
|
|
42
|
+
---
|
|
43
|
+
|
|
41
44
|
## 📸 Screenshots
|
|
42
45
|
| | |
|
|
43
46
|
| :---: | :---: |
|
|
@@ -67,6 +70,179 @@ leveraging the `mpv` engine for local media processing and playback.
|
|
|
67
70
|
|
|
68
71
|
---
|
|
69
72
|
|
|
73
|
+
## 🚀 Quick Install
|
|
74
|
+
|
|
75
|
+
We strongly recommend using **`pipx`** on modern macOS/Linux systems (PEP 668).
|
|
76
|
+
|
|
77
|
+
### 1. Recommended Method (pipx)
|
|
78
|
+
Automatically creates an isolated environment and registers the command.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Ensure ensuredpath is run to register the command after pipx install!
|
|
82
|
+
pipx install mytunes-pro
|
|
83
|
+
pipx ensurepath
|
|
84
|
+
source ~/.zshrc # or source ~/.bashrc (apply immediately to current terminal)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. Standard pip Method
|
|
88
|
+
If you encounter the `externally-managed-environment` error, add the following flag:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install mytunes-pro --break-system-packages
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
After installation, type **`mp`** anywhere in the terminal to run!
|
|
95
|
+
|
|
96
|
+
### 🔄 Update
|
|
97
|
+
If already installed, simply use the command below to update to the latest features:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pipx upgrade mytunes-pro
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 🛠 Prerequisites
|
|
106
|
+
|
|
107
|
+
Please install the necessary tools for your operating system before running.
|
|
108
|
+
|
|
109
|
+
### macOS (Using Homebrew)
|
|
110
|
+
```bash
|
|
111
|
+
brew install mpv python3 pipx
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Linux (Ubuntu/Debian)
|
|
115
|
+
```bash
|
|
116
|
+
sudo apt update
|
|
117
|
+
sudo apt install mpv python3 python3-pip pipx python3-venv -y
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Windows (WSL Guide)
|
|
121
|
+
|
|
122
|
+
Guide for Windows users where Korean search might not work or installation is difficult.
|
|
123
|
+
|
|
124
|
+
> **❓ What is WSL?**
|
|
125
|
+
> It allows you to run Linux environments directly on Windows. MyTunes works perfectly in this environment.
|
|
126
|
+
|
|
127
|
+
1. **Install WSL**:
|
|
128
|
+
- Right-click `Start` -> Run `Terminal (Admin)`.
|
|
129
|
+
- Enter the command below and **Reboot**:
|
|
130
|
+
```powershell
|
|
131
|
+
wsl --install -d Debian
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
2. **Install Essentials**:
|
|
135
|
+
```bash
|
|
136
|
+
sudo apt update && sudo apt install mpv python3-pip pipx -y
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
3. **Install MyTunes**:
|
|
140
|
+
```bash
|
|
141
|
+
pipx install mytunes-pro
|
|
142
|
+
pipx ensurepath
|
|
143
|
+
source ~/.bashrc # Apply settings immediately
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 🧑💻 Manual Installation (For Developers)
|
|
149
|
+
|
|
150
|
+
Follow these steps to modify source code or use the development version.
|
|
151
|
+
|
|
152
|
+
1. **Clone Repository**:
|
|
153
|
+
```bash
|
|
154
|
+
git clone https://github.com/postgresql-co-kr/mytunes.git
|
|
155
|
+
cd mytunes
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
2. **Setup Virtual Environment**:
|
|
159
|
+
```bash
|
|
160
|
+
python3 -m venv venv
|
|
161
|
+
source venv/bin/activate # macOS/Linux
|
|
162
|
+
pip install -r requirements.txt
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
3. **Run**:
|
|
166
|
+
```bash
|
|
167
|
+
python3 mytune.py
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## ⌨️ Controls
|
|
173
|
+
|
|
174
|
+
**MyTunes Pro** is controlled entirely by keyboard.
|
|
175
|
+
We recommend using **Number Keys** for lag-free operation even in multi-language input modes.
|
|
176
|
+
|
|
177
|
+
### ⚡️ Instant Hotkeys (Number Keys)
|
|
178
|
+
Executes immediately without worrying about input language status.
|
|
179
|
+
|
|
180
|
+
| Key | Function | Description |
|
|
181
|
+
| :--- | :--- | :--- |
|
|
182
|
+
| **`1`** | **Search** | Open music search (Same as `S`) |
|
|
183
|
+
| **`2`** | **Favorites** | View favorites list (Same as `F`) |
|
|
184
|
+
| **`3`** | **History** | View recently played 100 tracks (Same as `R`) |
|
|
185
|
+
| **`4`** | **Main** | Return to main screen (Same as `M`) |
|
|
186
|
+
| **`5`** | **Add/Del** | Toggle favorite for selected track (Same as `A`) |
|
|
187
|
+
| **`+`** | **Vol UP** | Volume +5% (Same as `=`) |
|
|
188
|
+
| **`-`** | **Vol DOWN** | Volume -5% (Same as `_`) |
|
|
189
|
+
| **`F7`** | **Open YouTube** | View current track in browser |
|
|
190
|
+
| **`6`** | **Back** | Go to previous screen (Same as `Q`, `h`) |
|
|
191
|
+
| **`L`** | **Forward** | Go forward to previous screen (`Right Arrow`) |
|
|
192
|
+
| **`ESC`** | **Background** | **Exit without stopping music** (Background Play) |
|
|
193
|
+
|
|
194
|
+
### 🧭 Basic Navigation
|
|
195
|
+
| Key | Action |
|
|
196
|
+
| :--- | :--- |
|
|
197
|
+
| `↑` / `↓` / `k` / `j` | Move selection Up/Down (Vim keys supported) |
|
|
198
|
+
| `Enter` / `l` | **Select / Play** |
|
|
199
|
+
| `Space` | Play / Pause |
|
|
200
|
+
| `-` / `+` | **Volume Control** |
|
|
201
|
+
| `,` / `.` | Rewind / Forward 10s |
|
|
202
|
+
| `<` / `>` | Rewind / Forward 30s (Shift) |
|
|
203
|
+
| `Backspace` / `h` / `q` | Go Back / Clear Search |
|
|
204
|
+
| `L` | **Go Forward** |
|
|
205
|
+
| `/` | **Search** (Vim Style) |
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 📂 Data Storage
|
|
210
|
+
- Favorites and playback history are permanently saved in `~/.pymusic_data.json` in your home directory.
|
|
211
|
+
- Data is preserved even after restarting the program.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
# 🎵 MyTunes Pro (Experimental Media Tool - KR)
|
|
217
|
+
|
|
218
|
+
## 🚀 터미널 기반 미디어 워크플로우 실험 v2.0.5
|
|
219
|
+
|
|
220
|
+
> [!IMPORTANT]
|
|
221
|
+
> **법적 면책 고지:** 본 프로젝트는 개발자 교육 및 연구를 목적으로 하는 개인적, 비상업적 실험입니다.
|
|
222
|
+
> 본 소프트웨어는 어떠한 미디어 콘텐츠도 직접 호스팅하거나 배포하지 않습니다.
|
|
223
|
+
> 모든 미디어 소스는 사용자의 로컬 환경에서 직접 구성되고 접근되며, 사용자는 외부 플랫폼의 이용 약관을 준수할 책임이 있습니다.
|
|
224
|
+
|
|
225
|
+
MyTunes Pro는 개발자를 위해 설계된 **CLI 미디어 실험 도구**입니다.
|
|
226
|
+
Python `curses` 라이브러리를 통해 터미널 환경에서 미디어 URL을 로드하고 관리하며,
|
|
227
|
+
사용자가 설치한 `mpv` 등의 외부 도구와 연동하여 미디어 워크플로우를 테스트할 수 있습니다.
|
|
228
|
+
|
|
229
|
+
## ✨ 주요 특징
|
|
230
|
+
|
|
231
|
+
- **미디어 핸들링**: 외부 추출 도구를 사용한 미디어 URL 로드 및 처리 지원.
|
|
232
|
+
- **TUI 워크플로우**: `curses` 라이브러리 기반의 효율적인 터미널 인터페이스.
|
|
233
|
+
- **작업 유지**: 순차적 미디어 로딩 및 마지막 작업 상태 복원 기능.
|
|
234
|
+
- **환경 연동**: 사용자에 의해 구성된 외부 미디어 도구와의 연동 지원. (본 소프트웨어는 외부 도구를 포함하여 배포하지 않습니다.)
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 📸 스크린샷 (Screenshots)
|
|
239
|
+
| | |
|
|
240
|
+
| :---: | :---: |
|
|
241
|
+
|  |  |
|
|
242
|
+
|  |  |
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
70
246
|
## 🚀 빠른 설치 (Quick Install)
|
|
71
247
|
|
|
72
248
|
최신 macOS/Linux 시스템(PEP 668)에서는 **`pipx`** 사용을 강력히 권장합니다.
|
|
@@ -128,12 +304,12 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
128
304
|
wsl --install -d Debian
|
|
129
305
|
```
|
|
130
306
|
|
|
131
|
-
|
|
307
|
+
2. **필수 도구 설치**:
|
|
132
308
|
```bash
|
|
133
309
|
sudo apt update && sudo apt install mpv python3-pip pipx -y
|
|
134
310
|
```
|
|
135
311
|
|
|
136
|
-
|
|
312
|
+
3. **MyTunes 설치**:
|
|
137
313
|
```bash
|
|
138
314
|
pipx install mytunes-pro
|
|
139
315
|
pipx ensurepath
|
|
@@ -210,32 +386,15 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
210
386
|
---
|
|
211
387
|
---
|
|
212
388
|
|
|
213
|
-
---
|
|
214
|
-
|
|
215
|
-
# 🎵 MyTunes Pro (Experimental Media Tool - KR)
|
|
216
|
-
|
|
217
|
-
## 🚀 터미널 기반 미디어 워크플로우 실험 v2.0.3
|
|
218
|
-
|
|
219
|
-
> [!IMPORTANT]
|
|
220
|
-
> **법적 면책 고지:** 본 프로젝트는 개발자 교육 및 연구를 목적으로 하는 개인적, 비상업적 실험입니다.
|
|
221
|
-
> 본 소프트웨어는 어떠한 미디어 콘텐츠도 직접 호스팅하거나 배포하지 않습니다.
|
|
222
|
-
> 모든 미디어 소스는 사용자의 로컬 환경에서 직접 구성되고 접근되며, 사용자는 외부 플랫폼의 이용 약관을 준수할 책임이 있습니다.
|
|
223
|
-
|
|
224
|
-
MyTunes Pro는 개발자를 위해 설계된 **CLI 미디어 실험 도구**입니다.
|
|
225
|
-
Python `curses` 라이브러리를 통해 터미널 환경에서 미디어 URL을 로드하고 관리하며,
|
|
226
|
-
사용자가 설치한 `mpv` 등의 외부 도구와 연동하여 미디어 워크플로우를 테스트할 수 있습니다.
|
|
227
|
-
|
|
228
|
-
## ✨ 주요 특징
|
|
229
|
-
|
|
230
|
-
- **미디어 핸들링**: 외부 추출 도구를 사용한 미디어 URL 로드 및 처리 지원.
|
|
231
|
-
- **TUI 워크플로우**: `curses` 라이브러리 기반의 효율적인 터미널 인터페이스.
|
|
232
|
-
- **작업 유지**: 순차적 미디어 로딩 및 마지막 작업 상태 복원 기능.
|
|
233
|
-
- **환경 연동**: 사용자에 의해 구성된 외부 미디어 도구와의 연동 지원. (본 소프트웨어는 외부 도구를 포함하여 배포하지 않습니다.)
|
|
234
|
-
|
|
235
|
-
---
|
|
236
|
-
|
|
237
389
|
## 🔄 Changelog
|
|
238
390
|
|
|
391
|
+
### v2.0.5 (2026-02-01)
|
|
392
|
+
- **Input Feedback Refinement**: Transitioned from blinking warnings to a static Bold Yellow status message for better accessibility and premium feel.
|
|
393
|
+
- **Auto-clear Optimization**: Implemented a 5-second auto-clear timer for all transient status messages.
|
|
394
|
+
- **Zero Latency Feedback**: Added instant redraw mechanisms to ensure input warnings appear immediately upon key press.
|
|
395
|
+
- **Stability Fixes**: Resolved a critical attribute error that caused crashes when selecting menu items.
|
|
396
|
+
- **SSL Compatibility**: Improved `urllib3` compatibility for macOS systems using LibreSSL.
|
|
397
|
+
|
|
239
398
|
### v2.0.4 (2026-02-01)
|
|
240
399
|
- **Legal Polish**: Comprehensive scrubbing of brand identifiers and service-oriented terminology across the ecosystem.
|
|
241
400
|
- **Localization**: Fully localized Korean landing page and technical experiment descriptions.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mytunes/app.py,sha256=uZUIV2dCPFZh0ONTazup5wwP6dqEOthtgOITA99iFrk,80327
|
|
3
|
+
mytunes_pro-2.0.6.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
|
|
4
|
+
mytunes_pro-2.0.6.dist-info/METADATA,sha256=r_WP429frS7K5SRuLiWpYAq70DHchDu_v_YzSYPPnjs,27583
|
|
5
|
+
mytunes_pro-2.0.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
mytunes_pro-2.0.6.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
|
|
7
|
+
mytunes_pro-2.0.6.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
|
|
8
|
+
mytunes_pro-2.0.6.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
mytunes/app.py,sha256=iu9LN4WtJrSQLSMreBMZqnXYotm_9Bk5eoQVTxk6dCo,57463
|
|
3
|
-
mytunes_pro-2.0.4.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
|
|
4
|
-
mytunes_pro-2.0.4.dist-info/METADATA,sha256=H_ujUXf1ofYs1seveke-gV_ivc-VAPYU9kKJgpaCpdE,22542
|
|
5
|
-
mytunes_pro-2.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
-
mytunes_pro-2.0.4.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
|
|
7
|
-
mytunes_pro-2.0.4.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
|
|
8
|
-
mytunes_pro-2.0.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|