mytunes-pro 2.0.5__py3-none-any.whl → 2.0.7__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 +499 -106
- {mytunes_pro-2.0.5.dist-info → mytunes_pro-2.0.7.dist-info}/METADATA +195 -31
- mytunes_pro-2.0.7.dist-info/RECORD +8 -0
- mytunes_pro-2.0.5.dist-info/RECORD +0 -8
- {mytunes_pro-2.0.5.dist-info → mytunes_pro-2.0.7.dist-info}/WHEEL +0 -0
- {mytunes_pro-2.0.5.dist-info → mytunes_pro-2.0.7.dist-info}/entry_points.txt +0 -0
- {mytunes_pro-2.0.5.dist-info → mytunes_pro-2.0.7.dist-info}/licenses/LICENSE +0 -0
- {mytunes_pro-2.0.5.dist-info → mytunes_pro-2.0.7.dist-info}/top_level.txt +0 -0
mytunes/app.py
CHANGED
|
@@ -44,7 +44,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
|
|
|
44
44
|
LOG_FILE = "/tmp/mytunes_mpv.log"
|
|
45
45
|
PID_FILE = "/tmp/mytunes_mpv.pid"
|
|
46
46
|
APP_NAME = "MyTunes Pro"
|
|
47
|
-
APP_VERSION = "2.0.
|
|
47
|
+
APP_VERSION = "2.0.7"
|
|
48
48
|
|
|
49
49
|
# === [Strings & Localization] ===
|
|
50
50
|
STRINGS = {
|
|
@@ -64,7 +64,8 @@ STRINGS = {
|
|
|
64
64
|
"fav_added": "★ 즐겨찾기에 추가됨",
|
|
65
65
|
"fav_removed": "☆ 즐겨찾기 해제됨",
|
|
66
66
|
"header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
|
|
67
|
-
"
|
|
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]삭제",
|
|
68
69
|
"help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [F7]유튜브",
|
|
69
70
|
"menu_main": "☰ 메인 메뉴",
|
|
70
71
|
"menu_search_results": "⌕ 미디어 콘텐츠 검색",
|
|
@@ -95,7 +96,8 @@ STRINGS = {
|
|
|
95
96
|
"fav_added": "★ Added to Favorites",
|
|
96
97
|
"fav_removed": "☆ Removed from Favorites",
|
|
97
98
|
"header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
|
|
98
|
-
"
|
|
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",
|
|
99
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",
|
|
100
102
|
"menu_main": "☰ Main Menu",
|
|
101
103
|
"menu_search_results": "⌕ Search Media Content",
|
|
@@ -110,7 +112,28 @@ STRINGS = {
|
|
|
110
112
|
"ime_warning": "Switch to English for shortcuts.",
|
|
111
113
|
"invalid_key": "Invalid key: '{}'"
|
|
112
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
|
|
113
135
|
}
|
|
136
|
+
EQUALIZER_KEYS = ["Auto"] + list(EQUALIZER_PRESETS.keys())
|
|
114
137
|
|
|
115
138
|
class DataManager:
|
|
116
139
|
def __init__(self):
|
|
@@ -148,6 +171,12 @@ class DataManager:
|
|
|
148
171
|
def set_progress(self, url, time_pos):
|
|
149
172
|
if "resume" not in self.data: self.data["resume"] = {}
|
|
150
173
|
self.data["resume"][url] = time_pos
|
|
174
|
+
# Limit resume data to 500 entries (FIFO cleanup)
|
|
175
|
+
if len(self.data["resume"]) > 500:
|
|
176
|
+
# Remove oldest entries (first 100)
|
|
177
|
+
keys = list(self.data["resume"].keys())
|
|
178
|
+
for k in keys[:100]:
|
|
179
|
+
del self.data["resume"][k]
|
|
151
180
|
|
|
152
181
|
def add_history(self, item):
|
|
153
182
|
self.data['history'] = [h for h in self.data['history'] if h['url'] != item['url']]
|
|
@@ -288,7 +317,7 @@ class Player:
|
|
|
288
317
|
subprocess.run(["pkill", "-f", "mpv --video=no"], stderr=subprocess.DEVNULL)
|
|
289
318
|
except: pass
|
|
290
319
|
|
|
291
|
-
def play(self, url, start_pos=0):
|
|
320
|
+
def play(self, url, start_pos=0, initial_eq_preset="Flat"):
|
|
292
321
|
# 1. Try to reuse existing instance via IPC (Graceful)
|
|
293
322
|
if os.path.exists(MPV_SOCKET):
|
|
294
323
|
try:
|
|
@@ -297,6 +326,11 @@ class Player:
|
|
|
297
326
|
if resp and not resp.get("error"):
|
|
298
327
|
if start_pos > 0:
|
|
299
328
|
self.send_cmd(["seek", str(start_pos), "absolute"])
|
|
329
|
+
|
|
330
|
+
# Apply EQ immediately for reused instance
|
|
331
|
+
if initial_eq_preset and initial_eq_preset != "Flat":
|
|
332
|
+
self.set_equalizer(initial_eq_preset)
|
|
333
|
+
|
|
300
334
|
self.loading = True
|
|
301
335
|
self.loading_ts = time.time()
|
|
302
336
|
return # Success! No need to restart
|
|
@@ -318,10 +352,16 @@ class Player:
|
|
|
318
352
|
"mpv", "--video=no", "--vo=null", "--force-window=no",
|
|
319
353
|
"--audio-display=no", "--no-config",
|
|
320
354
|
f"--input-ipc-server={MPV_SOCKET}",
|
|
321
|
-
"--idle=yes"
|
|
322
|
-
url
|
|
355
|
+
"--idle=yes"
|
|
323
356
|
]
|
|
324
357
|
|
|
358
|
+
# Inject Initial EQ (0ms Latency)
|
|
359
|
+
eq_af = self._get_eq_af_string(initial_eq_preset)
|
|
360
|
+
if eq_af:
|
|
361
|
+
cmd.append(f"--af={eq_af}")
|
|
362
|
+
|
|
363
|
+
cmd.append(url)
|
|
364
|
+
|
|
325
365
|
# B. macOS Specific UI Optimizations
|
|
326
366
|
if sys.platform == "darwin":
|
|
327
367
|
# 'accessory' hides Dock but allows system resources
|
|
@@ -420,6 +460,25 @@ class Player:
|
|
|
420
460
|
"""Seek relative to current position."""
|
|
421
461
|
self.send_cmd(["seek", seconds, "relative"])
|
|
422
462
|
|
|
463
|
+
def _get_eq_af_string(self, preset_name):
|
|
464
|
+
"""Generate lavfi string for EQ preset."""
|
|
465
|
+
gains = EQUALIZER_PRESETS.get(preset_name, EQUALIZER_PRESETS["Flat"])
|
|
466
|
+
if preset_name == "Flat": return ""
|
|
467
|
+
|
|
468
|
+
freqs = [31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
|
469
|
+
filters = []
|
|
470
|
+
for f, g in zip(freqs, gains):
|
|
471
|
+
if g != 0:
|
|
472
|
+
filters.append(f"equalizer=f={f}:width_type=o:width=1:g={g}")
|
|
473
|
+
|
|
474
|
+
if not filters: return ""
|
|
475
|
+
return "lavfi=[" + ",".join(filters) + "]"
|
|
476
|
+
|
|
477
|
+
def set_equalizer(self, preset_name):
|
|
478
|
+
"""Apply 10-band equalizer preset using lavfi."""
|
|
479
|
+
af_str = self._get_eq_af_string(preset_name)
|
|
480
|
+
self.set_property("af", af_str)
|
|
481
|
+
|
|
423
482
|
# === [TUI Application] ===
|
|
424
483
|
class MyTunesApp:
|
|
425
484
|
def __init__(self, stdscr):
|
|
@@ -436,12 +495,19 @@ class MyTunesApp:
|
|
|
436
495
|
self.scroll_offset = 0
|
|
437
496
|
self.current_track = None
|
|
438
497
|
self.cached_history = [] # Snapshot for stable history view
|
|
439
|
-
self.status_msg = ""
|
|
498
|
+
self.status_msg = "" # Deprecated, keeping for safety until full refactor
|
|
499
|
+
self.feedback_msg = ""
|
|
500
|
+
self.feedback_expiry = 0
|
|
501
|
+
self.view_msg = ""
|
|
440
502
|
|
|
441
503
|
# Queue System
|
|
442
504
|
self.queue = []
|
|
443
505
|
self.queue_idx = -1
|
|
444
506
|
|
|
507
|
+
# Audio EQ
|
|
508
|
+
self.current_eq_index = 0 # Default to 0 -> Flat
|
|
509
|
+
self.eq_overlay_time = 0 # For showing EQ change status temporarily
|
|
510
|
+
|
|
445
511
|
# Search State
|
|
446
512
|
self.current_search_query = None
|
|
447
513
|
# self.search_page = 1 # Deprecated: Pagination Removed v2.0.2
|
|
@@ -454,6 +520,8 @@ class MyTunesApp:
|
|
|
454
520
|
self.is_paused = False
|
|
455
521
|
self.last_save_time = time.time()
|
|
456
522
|
self.status_set_time = 0
|
|
523
|
+
self.auto_preset_name = "Pop" # Default Auto detected genre
|
|
524
|
+
self._eq_cache = {} # Cache: {url: detected_genre} to avoid re-computation
|
|
457
525
|
|
|
458
526
|
# Throttling Counters
|
|
459
527
|
self.loop_count = 0
|
|
@@ -486,6 +554,17 @@ class MyTunesApp:
|
|
|
486
554
|
self.sent_history = {}
|
|
487
555
|
|
|
488
556
|
|
|
557
|
+
def show_feedback(self, msg, duration=2.5):
|
|
558
|
+
"""Show transient feedback (keys/errors) that overrides view status temporarily."""
|
|
559
|
+
self.feedback_msg = msg
|
|
560
|
+
self.feedback_expiry = time.time() + duration
|
|
561
|
+
self.draw() # Force immediate update
|
|
562
|
+
|
|
563
|
+
def set_view_status(self, msg):
|
|
564
|
+
"""Set persistent status specific to the current view (e.g., Favorites path)."""
|
|
565
|
+
self.view_msg = msg
|
|
566
|
+
# We don't force draw here usually, the loop handles it, or caller does.
|
|
567
|
+
|
|
489
568
|
def handle_disconnect(self, signum, frame):
|
|
490
569
|
"""Auto-background if terminal disconnects."""
|
|
491
570
|
self.stop_on_exit = False
|
|
@@ -564,7 +643,7 @@ class MyTunesApp:
|
|
|
564
643
|
now = time.time()
|
|
565
644
|
if self.player.loading and (now - self.player.loading_ts > 8):
|
|
566
645
|
self.player.loading = False
|
|
567
|
-
self.
|
|
646
|
+
self.show_feedback("⚠️ Load timed out. Skipping...")
|
|
568
647
|
|
|
569
648
|
# 2. Frequent: Pause state (Every 2 loops ~400ms)
|
|
570
649
|
if self.loop_count % 2 == 0:
|
|
@@ -660,7 +739,8 @@ class MyTunesApp:
|
|
|
660
739
|
"<": "SEEK_BACK_30", ">": "SEEK_FWD_30",
|
|
661
740
|
"a": "TOGGLE_FAV", "5": "TOGGLE_FAV",
|
|
662
741
|
str(curses.KEY_F7): "OPEN_BROWSER",
|
|
663
|
-
str(curses.KEY_DC): "DELETE", "d": "DELETE"
|
|
742
|
+
str(curses.KEY_DC): "DELETE", "d": "DELETE",
|
|
743
|
+
"e": "CYCLE_EQ", "E": "CYCLE_EQ"
|
|
664
744
|
}
|
|
665
745
|
cmd = mapping.get(k_char)
|
|
666
746
|
if cmd: return cmd
|
|
@@ -682,12 +762,12 @@ class MyTunesApp:
|
|
|
682
762
|
if cmd == "NAV_BACK":
|
|
683
763
|
if len(self.view_stack) > 1:
|
|
684
764
|
self.forward_stack.append(self.view_stack.pop())
|
|
685
|
-
self.selection_idx = 0; self.scroll_offset = 0; self.
|
|
765
|
+
self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
|
|
686
766
|
|
|
687
767
|
elif cmd == "NAV_FORWARD":
|
|
688
768
|
if self.forward_stack:
|
|
689
769
|
self.view_stack.append(self.forward_stack.pop())
|
|
690
|
-
self.selection_idx = 0; self.scroll_offset = 0; self.
|
|
770
|
+
self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
|
|
691
771
|
|
|
692
772
|
elif cmd == "MOVE_UP":
|
|
693
773
|
if self.selection_idx > 0:
|
|
@@ -718,31 +798,31 @@ class MyTunesApp:
|
|
|
718
798
|
elif cmd == "FAVORITES":
|
|
719
799
|
if self.view_stack[-1] != "favorites":
|
|
720
800
|
self.forward_stack = []; self.view_stack.append("favorites"); self.selection_idx = 0
|
|
721
|
-
self.
|
|
801
|
+
self.set_view_status(self.t("favorites_info", DATA_FILE))
|
|
722
802
|
|
|
723
803
|
elif cmd == "HISTORY":
|
|
724
804
|
if self.view_stack[-1] != "history":
|
|
725
805
|
self.forward_stack = []; self.cached_history = list(self.dm.data['history'])
|
|
726
806
|
self.view_stack.append("history"); self.selection_idx = 0
|
|
727
|
-
self.
|
|
807
|
+
self.set_view_status(self.t("hist_info"))
|
|
728
808
|
|
|
729
809
|
elif cmd == "MAIN_MENU":
|
|
730
|
-
self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.
|
|
810
|
+
self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
|
|
731
811
|
|
|
732
812
|
elif cmd == "TOGGLE_PAUSE": self.player.toggle_pause()
|
|
733
|
-
elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.
|
|
734
|
-
elif cmd == "VOL_UP": self.player.change_volume(5); self.
|
|
813
|
+
elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.show_feedback("Volume -5")
|
|
814
|
+
elif cmd == "VOL_UP": self.player.change_volume(5); self.show_feedback("Volume +5")
|
|
735
815
|
elif cmd == "SEEK_BACK_10": self.player.seek(-10)
|
|
736
816
|
elif cmd == "SEEK_FWD_10": self.player.seek(10)
|
|
737
|
-
elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.
|
|
738
|
-
elif cmd == "SEEK_FWD_30": self.player.seek(30); self.
|
|
817
|
+
elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.show_feedback("Rewind 30s")
|
|
818
|
+
elif cmd == "SEEK_FWD_30": self.player.seek(30); self.show_feedback("Forward 30s")
|
|
739
819
|
|
|
740
820
|
elif cmd == "TOGGLE_FAV":
|
|
741
821
|
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
742
822
|
target = current_list[self.selection_idx]
|
|
743
823
|
if "url" in target:
|
|
744
824
|
is_added = self.dm.toggle_favorite(target)
|
|
745
|
-
self.
|
|
825
|
+
self.show_feedback(self.t("fav_added") if is_added else self.t("fav_removed"))
|
|
746
826
|
|
|
747
827
|
elif cmd == "DELETE":
|
|
748
828
|
self.handle_deletion(current_list)
|
|
@@ -768,16 +848,19 @@ class MyTunesApp:
|
|
|
768
848
|
elif cmd == "EXIT_BKG":
|
|
769
849
|
self.stop_on_exit = False; self.running = False
|
|
770
850
|
|
|
851
|
+
elif cmd == "CYCLE_EQ":
|
|
852
|
+
self.cycle_equalizer()
|
|
853
|
+
|
|
771
854
|
elif isinstance(cmd, tuple) and cmd[0] == "UNKNOWN":
|
|
772
855
|
key = cmd[1]
|
|
773
856
|
if isinstance(key, str) and ord(key[0]) > 127:
|
|
774
|
-
self.
|
|
857
|
+
self.show_feedback(self.t("ime_warning"))
|
|
775
858
|
self.status_set_time = time.time()
|
|
776
|
-
self.draw() #
|
|
859
|
+
# self.draw() # Handled by show_feedback
|
|
777
860
|
elif isinstance(key, str) and key.isprintable():
|
|
778
|
-
self.
|
|
861
|
+
self.show_feedback(self.t("invalid_key", key))
|
|
779
862
|
self.status_set_time = time.time()
|
|
780
|
-
self.draw() #
|
|
863
|
+
# self.draw() # Handled by show_feedback
|
|
781
864
|
|
|
782
865
|
def handle_deletion(self, current_list):
|
|
783
866
|
"""Sub-logic for DELETE command to keep dispatcher clean."""
|
|
@@ -787,23 +870,297 @@ class MyTunesApp:
|
|
|
787
870
|
success = False
|
|
788
871
|
if view == "favorites":
|
|
789
872
|
success = self.dm.remove_favorite_by_index(self.selection_idx)
|
|
790
|
-
if success: self.
|
|
873
|
+
if success: self.show_feedback("🗑️ Deleted from Favorites")
|
|
791
874
|
elif view == "history":
|
|
792
875
|
success = self.dm.remove_history_by_index(self.selection_idx)
|
|
793
|
-
if success: self.cached_history = list(self.dm.data['history']); self.
|
|
876
|
+
if success: self.cached_history = list(self.dm.data['history']); self.show_feedback("🗑️ Deleted from History")
|
|
794
877
|
elif view == "search":
|
|
795
878
|
if self.current_search_query is None:
|
|
796
879
|
success = self.dm.remove_search_history_by_index(self.selection_idx)
|
|
797
|
-
if success: self.search_results = self.dm.get_search_history(); self.
|
|
880
|
+
if success: self.search_results = self.dm.get_search_history(); self.show_feedback("🗑️ Deleted from Search History")
|
|
798
881
|
else:
|
|
799
|
-
try: self.search_results.pop(self.selection_idx); success = True; self.
|
|
882
|
+
try: self.search_results.pop(self.selection_idx); success = True; self.show_feedback("Removed from list")
|
|
800
883
|
except: pass
|
|
801
|
-
if success:
|
|
802
|
-
self.selection_idx = max(0, min(self.selection_idx, len(self.get_current_list()) - 1))
|
|
803
884
|
|
|
885
|
+
def cycle_equalizer(self):
|
|
886
|
+
self.current_eq_index = (self.current_eq_index + 1) % len(EQUALIZER_KEYS)
|
|
887
|
+
new_preset = EQUALIZER_KEYS[self.current_eq_index]
|
|
888
|
+
|
|
889
|
+
if new_preset == "Auto":
|
|
890
|
+
# Immediate trigger for current track
|
|
891
|
+
auto_preset = self.detect_auto_eq(self.current_track) if self.current_track else "Pop"
|
|
892
|
+
self.player.set_equalizer(auto_preset)
|
|
893
|
+
self.show_feedback(f"🎚 Auto: {auto_preset}") # Explicit feedback on change
|
|
894
|
+
else:
|
|
895
|
+
self.player.set_equalizer(new_preset)
|
|
896
|
+
self.show_feedback(f"🎚 EQ: {new_preset}") # Explicit feedback on change
|
|
897
|
+
|
|
898
|
+
self.status_set_time = time.time()
|
|
899
|
+
|
|
900
|
+
def detect_auto_eq(self, item):
|
|
901
|
+
"""
|
|
902
|
+
Smart genre detection using weighted scoring.
|
|
903
|
+
Analyzes Title and Author for keywords.
|
|
904
|
+
"""
|
|
905
|
+
# Cache check: return cached result if available
|
|
906
|
+
if not isinstance(item, str):
|
|
907
|
+
url = item.get('url', '')
|
|
908
|
+
if url and url in self._eq_cache:
|
|
909
|
+
cached = self._eq_cache[url]
|
|
910
|
+
self.auto_preset_name = cached
|
|
911
|
+
return cached
|
|
912
|
+
# Limit cache size to 200 entries
|
|
913
|
+
if len(self._eq_cache) > 200:
|
|
914
|
+
# Remove first 50 entries
|
|
915
|
+
keys = list(self._eq_cache.keys())
|
|
916
|
+
for k in keys[:50]:
|
|
917
|
+
del self._eq_cache[k]
|
|
918
|
+
|
|
919
|
+
# Prepare texts
|
|
920
|
+
title_text = ""
|
|
921
|
+
extra_text = ""
|
|
922
|
+
|
|
923
|
+
if isinstance(item, str):
|
|
924
|
+
title_text = item.lower()
|
|
925
|
+
else:
|
|
926
|
+
title_text = item.get('title', '').lower()
|
|
927
|
+
# extra_text removed to strictly follow title-based detection
|
|
928
|
+
|
|
929
|
+
# DEBUG: Log detection attempt
|
|
930
|
+
# (Debug code removed)
|
|
931
|
+
|
|
932
|
+
scores = {k: 0 for k in EQUALIZER_PRESETS.keys() if k != "Flat"}
|
|
933
|
+
|
|
934
|
+
# Multilingual Genre Keywords (En, Ko, Ja, Zh, Ru, Es, Fr, Vi)
|
|
935
|
+
# Priorities specific genre words over artist names for accuracy.
|
|
936
|
+
rules = [
|
|
937
|
+
("Rock", [
|
|
938
|
+
"rock", "metal", "grunge", "punk", "band", "guitar solo", "drum",
|
|
939
|
+
"락", "록", "메탈", "밴드", "기타", # Ko
|
|
940
|
+
"ロック", "メタル", "パンク", "バンド", # Ja
|
|
941
|
+
"摇滚", "金属乐", "乐队", # Zh
|
|
942
|
+
"рок", "metal", "панк", "группа", # Ru
|
|
943
|
+
"roca", "metal", "punk", "banda", # Es
|
|
944
|
+
"rocher", "métal", "groupe", # Fr
|
|
945
|
+
"nhạc rock", "ban nhạc", # Vi
|
|
946
|
+
"roque", "metal", "banda", "guitarra", # Pt
|
|
947
|
+
"rock", "metall", "band", "gitarre", # De
|
|
948
|
+
"rock", "band", # Hi
|
|
949
|
+
"ร็อค", "วง", "กีตาร์", # Th
|
|
950
|
+
"queen", "ac/dc", "nirvana", "linkin park", "oasis", "coldplay" # Iconic Fallbacks
|
|
951
|
+
]),
|
|
952
|
+
("Jazz", [
|
|
953
|
+
"jazz", "blues", "piano", "saxophone", "trumpet", "cafe", "coffee", "lounge", "smooth", "relaxing", "dinner", "wine", "bar", "mood",
|
|
954
|
+
"재즈", "블루스", "피아노", "카페", "커피", "라운지", "무드", # Ko
|
|
955
|
+
"ジャズ", "ブルース", "ピアノ", "カフェ", "ラウンジ", # Ja
|
|
956
|
+
"爵士", "蓝调", "钢琴", "咖啡", # Zh
|
|
957
|
+
"джаз", "блюз", "пианино", "кафе", "лаунж", # Ru
|
|
958
|
+
"jazz", "blues", "piano", "café", "salón", # Es
|
|
959
|
+
"jazz", "blues", "piano", "café", "salon", # Fr
|
|
960
|
+
"nhạc jazz", "nhạc blues", "dương cầm", "cà phê", # Vi
|
|
961
|
+
"jazz", "blues", "piano", "bossa nova", "samba", "café", # Pt
|
|
962
|
+
"jazz", "blues", "klavier", "kaffee", # De
|
|
963
|
+
"jazz", "piano", # Hi
|
|
964
|
+
"แจ๊ส", "เปียโน", "กาแฟ", # Th
|
|
965
|
+
"norah jones", "chet baker", "bill evans"
|
|
966
|
+
]),
|
|
967
|
+
("Classical", [
|
|
968
|
+
"classical", "classic", "orchestra", "symphony", "concerto", "sonata", "violin", "cello", "opera", "choir", "philharmonic",
|
|
969
|
+
"클래식", "오케스트라", "교향곡", "협주곡", "소나타", "바이올린", "첼로", "오페라", "합창", # Ko
|
|
970
|
+
"クラシック", "オーケストラ", "交響曲", "協奏曲", "ソナタ", "バイオリン", "チェロ", # Ja
|
|
971
|
+
"古典", "交响乐", "协奏曲", "奏鸣曲", "小提琴", "大提琴", # Zh
|
|
972
|
+
"классика", "оркестр", "симфония", "концерт", "соната", "скрипка", "виолончель", # Ru
|
|
973
|
+
"clásica", "orquesta", "sinfonía", "concierto", # Es
|
|
974
|
+
"classique", "orchestre", "symphonie", "concerto", # Fr
|
|
975
|
+
"cổ điển", "dàn nhạc", "giao hưởng", # Vi
|
|
976
|
+
"clássica", "orquestra", "sinfonia", "piano", # Pt
|
|
977
|
+
"klassik", "orchester", "sinfonie", "klavier", # De
|
|
978
|
+
"classical", "orchestra", # Hi
|
|
979
|
+
"คลาสสิก", "ออเคสตรา", "เปียโน", # Th
|
|
980
|
+
"mozart", "bach", "beethoven", "chopin", "disney", "ghibli"
|
|
981
|
+
]),
|
|
982
|
+
("Club", [
|
|
983
|
+
"edm", "club", "dance floor", "remix", "mix", "dj", "techno", "house", "trance", "dubstep", "bass boost", "electronic",
|
|
984
|
+
"클럽", "리믹스", "믹스", "디제이", "테크노", "하우스", "일렉", "이디엠", # Ko
|
|
985
|
+
"クラブ", "リミックス", "テクノ", "ハウス", "エレクトロニック", # Ja
|
|
986
|
+
"俱乐部", "混音", "电音", "电子", # Zh
|
|
987
|
+
"клуб", "ремикс", "диджей", "техно", "хаус", "электроника", # Ru
|
|
988
|
+
"club", "remix", "mezcla", "electrónica", # Es
|
|
989
|
+
"club", "remix", "mélange", "électronique", # Fr
|
|
990
|
+
"câu lạc bộ", "phối lại", "điện tử", "nhạc sàn", # Vi
|
|
991
|
+
"clube", "remix", "eletrônica", "balada", # Pt
|
|
992
|
+
"club", "remix", "elektronisch", "techno", "nacht", # De
|
|
993
|
+
"club", "remix", "dj", # Hi
|
|
994
|
+
"คลับ", "รีมิกซ์", "ดีเจ", "แดนซ์" # Th
|
|
995
|
+
]),
|
|
996
|
+
("Dance", [
|
|
997
|
+
"dance", "disco", "party", "choreography", "upbeat", "idol", "kpop", "k-pop", "j-pop", "pop dance", "tango", "salsa", "swing",
|
|
998
|
+
"댄스", "디스코", "파티", "안무", "아이돌", "케이팝", "신나는", "탱고", "살사", # Ko
|
|
999
|
+
"ダンス", "ディスコ", "パーティー", "アイドル", "タンゴ", "サルサ", # Ja
|
|
1000
|
+
"舞曲", "迪斯科", "派对", "偶像", "探戈", "莎莎", # Zh
|
|
1001
|
+
"танец", "диско", "вечеринка", "айдол", "танго", # Ru
|
|
1002
|
+
"baile", "disco", "fiesta", "íbodo", "tango", "salsa", # Es
|
|
1003
|
+
"danse", "discothèque", "fête", "tango", "salsa", # Fr
|
|
1004
|
+
"nhảy", "khiêu vũ", "tiệc", "thần tượng", "tango", # Vi
|
|
1005
|
+
"dança", "festa", "funk", "ídolo", # Pt
|
|
1006
|
+
"tanz", "party", "schlager", # De
|
|
1007
|
+
"dance", "party", "bollywood", "nach", # Hi
|
|
1008
|
+
"เต้น", "ปาร์ตี้", "ไอดอล" # Th
|
|
1009
|
+
]),
|
|
1010
|
+
("Full Bass", [
|
|
1011
|
+
"hip hop", "hiphop", "rap", "r&b", "soul", "trap", "beat", "bass", "boom bap", "drill", "grime",
|
|
1012
|
+
"힙합", "랩", "알앤비", "소울", "트랩", "비트", "베이스", "쇼미더머니", # Ko
|
|
1013
|
+
"ヒップホップ", "ラップ", "ソウル", "トラップ", "ベース", # Ja
|
|
1014
|
+
"嘻哈", "说唱", "饶舌", "灵魂乐", "贝斯", # Zh
|
|
1015
|
+
"хип-хоп", "рэп", "соул", "трэп", "бас", # Ru
|
|
1016
|
+
"hip hop", "rap", "alma", "bajo", # Es
|
|
1017
|
+
"hip hop", "rap", "âme", "basse", # Fr
|
|
1018
|
+
"hip hop", "rap", "tâm hồn", # Vi
|
|
1019
|
+
"hip hop", "rap", "alma", "batida", # Pt
|
|
1020
|
+
"hip hop", "rap", "seele", # De
|
|
1021
|
+
"hip hop", "rap", # Hi
|
|
1022
|
+
"ฮิปฮอป", "แร็ป" # Th
|
|
1023
|
+
]),
|
|
1024
|
+
("Live", [
|
|
1025
|
+
"live", "concert", "performance", "stage", "tour", "fancam", "busking", "unplugged", "session",
|
|
1026
|
+
"라이브", "콘서트", "공연", "무대", "투어", "직캠", "버스킹", "실황", # Ko
|
|
1027
|
+
"ライブ", "コンサート", "パフォーマンス", "ステージ", "ツアー", # Ja
|
|
1028
|
+
"现场", "演唱会", "表演", "舞台", "巡演", # Zh
|
|
1029
|
+
"жить", "концерт", "выступление", "сцена", "тур", # Ru
|
|
1030
|
+
"vivo", "concierto", "rendimiento", "escenario", # Es
|
|
1031
|
+
"vivre", "concert", "performance", "scène", # Fr
|
|
1032
|
+
"trực tiếp", "buổi hòa nhạc", "biểu diễn", "sân khấu", # Vi
|
|
1033
|
+
"ao vivo", "concerto", "palco", # Pt
|
|
1034
|
+
"live", "konzert", "bühne", "auftritt", # De
|
|
1035
|
+
"live", "concert", # Hi
|
|
1036
|
+
"สด", "คอนเสิร์ต", "การแสดง" # Th
|
|
1037
|
+
]),
|
|
1038
|
+
("Soft", [
|
|
1039
|
+
"soft", "ballad", "acoustic", "lofi", "lo-fi", "chill", "relax", "sleep", "healing", "study", "reading", "winter", "rain", "snow", "night", "dawn", "morning", "piano", "guitar", "folk", "indie",
|
|
1040
|
+
"소프트", "발라드", "어쿠스틱", "로파이", "칠", "휴식", "자장가", "수면", "힐링", "공부", "독서", "겨울", "비", "눈", "밤", "새벽", "아침", "인디", "포크", "잔잔한", "감성", # Ko
|
|
1041
|
+
"ソフト", "バラード", "アコースティック", "ローファイ", "リラックス", "睡眠", "癒し", "勉強", "冬", "雨", "雪", "夜", "夜明け", # Ja
|
|
1042
|
+
"柔和", "民谣", "原声", "低保真", "放松", "睡眠", "治愈", "学习", "冬", "雨", "雪", "夜", # Zh
|
|
1043
|
+
"мягкий", "баллада", "акустика", "лоу-фай", "расслабляться", "спать", "исцеление", "зима", "дождь", "снег", "ночь", # Ru
|
|
1044
|
+
"suave", "balada", "acústico", "relajarse", "curación", "invierno", "lluvia", "nieve", "noche", # Es
|
|
1045
|
+
"doux", "ballade", "acoustique", "se détendre", "guérison", "hiver", "pluie", "neige", "nuit", # Fr
|
|
1046
|
+
"nhẹ nhàng", "bản ballad", "âm thanh", "thư giãn", "chữa lành", "mùa đông", "mưa", "tuyết", "đêm", # Vi
|
|
1047
|
+
"suave", "balada", "acústico", "relaxar", "sono", # Pt
|
|
1048
|
+
"weich", "ballade", "akustisch", "entspannung", "schlaf", "ruhig", # De
|
|
1049
|
+
"soft", "relax", "sukoon", # Hi
|
|
1050
|
+
"เบาๆ", "บัลลาด", "อะคูสติก", "ผ่อนคลาย", "นอนหลับ" # Th
|
|
1051
|
+
]),
|
|
1052
|
+
("Pop", [
|
|
1053
|
+
"pop", "hits", "best", "top", "chart", "trending", "billboard", "imayo", "kayo", "ost", "soundtrack", "city pop",
|
|
1054
|
+
"팝", "가요", "인기", "히트", "차트", "트렌드", "노래모음", "오에스티", "사운드트랙", "아이돌", "시티팝", "트로트", # Ko
|
|
1055
|
+
"ポップ", "ヒット", "ベスト", "チャート", "トレンド", "サウンドトラック", "シティポップ", "アニメ", # Ja
|
|
1056
|
+
"流行", "热门", "最佳", "榜单", "趋势", "原森", # Zh
|
|
1057
|
+
"поп", "хиты", "лучший", "диаграмма", "тенденция", "саундтрек", # Ru
|
|
1058
|
+
"pop", "éxitos", "mejor", "gráfico", "tendencia", "banda sonora", # Es
|
|
1059
|
+
"pop", "coups", "mieux", "graphique", "tendance", "bande sonore", # Fr
|
|
1060
|
+
"nhạc pop", "lượt truy cập", "tốt nhất", "biểu đồ", "xu hướng", "nhạc phim", # Vi
|
|
1061
|
+
"musica", "pop", "sucesso", "mais tocadas", # Pt
|
|
1062
|
+
"pop", "musik", "hits", "besten", "chart", # De
|
|
1063
|
+
"gana", "geet", "top", "best", # Hi
|
|
1064
|
+
"เพลง", "ป๊อป", "ฮิต", "ยอดนิยม" # Th
|
|
1065
|
+
])
|
|
1066
|
+
]
|
|
804
1067
|
|
|
1068
|
+
# Scoring
|
|
1069
|
+
for genre, keywords in rules:
|
|
1070
|
+
if genre not in scores: continue
|
|
1071
|
+
for k in keywords:
|
|
1072
|
+
# Title Match Only
|
|
1073
|
+
if k in title_text:
|
|
1074
|
+
score = 3
|
|
1075
|
+
# Boost specific keywords even more
|
|
1076
|
+
scores[genre] += score
|
|
1077
|
+
|
|
1078
|
+
# Find winner
|
|
1079
|
+
best_genre = max(scores, key=scores.get)
|
|
1080
|
+
|
|
1081
|
+
if scores[best_genre] > 0:
|
|
1082
|
+
self.auto_preset_name = best_genre
|
|
1083
|
+
# Save to cache
|
|
1084
|
+
if not isinstance(item, str) and item.get('url'):
|
|
1085
|
+
self._eq_cache[item['url']] = best_genre
|
|
1086
|
+
return best_genre
|
|
1087
|
+
|
|
1088
|
+
# Default Fallback
|
|
1089
|
+
self.auto_preset_name = "Pop"
|
|
1090
|
+
# Cache fallback too
|
|
1091
|
+
if not isinstance(item, str) and item.get('url'):
|
|
1092
|
+
self._eq_cache[item['url']] = "Pop"
|
|
1093
|
+
return "Pop"
|
|
805
1094
|
|
|
1095
|
+
def play_music(self, item, interactive=True, preserve_queue=False):
|
|
1096
|
+
if not item.get("url"): return # Guard against dummy items
|
|
1097
|
+
|
|
1098
|
+
self.current_track = item
|
|
1099
|
+
self.dm.add_history(item)
|
|
1100
|
+
|
|
1101
|
+
# Apply Auto EQ if enabled
|
|
1102
|
+
target_eq_preset = "Flat"
|
|
1103
|
+
current_eq_mode = EQUALIZER_KEYS[self.current_eq_index]
|
|
1104
|
+
|
|
1105
|
+
# Always run detection for debug logging and state freshness
|
|
1106
|
+
self.detect_auto_eq(item) # Updates self.auto_preset_name
|
|
1107
|
+
|
|
1108
|
+
if current_eq_mode == "Auto":
|
|
1109
|
+
target_eq_preset = self.auto_preset_name
|
|
1110
|
+
else:
|
|
1111
|
+
target_eq_preset = current_eq_mode
|
|
806
1112
|
|
|
1113
|
+
self.status_set_time = time.time()
|
|
1114
|
+
|
|
1115
|
+
# Queue Management
|
|
1116
|
+
if not preserve_queue:
|
|
1117
|
+
# New Queue Context from current view
|
|
1118
|
+
current_list = self.get_current_list()
|
|
1119
|
+
# Copy list to queue (Filter only playable items)
|
|
1120
|
+
self.queue = [i for i in current_list if i.get("url")]
|
|
1121
|
+
# Find index in queue
|
|
1122
|
+
try:
|
|
1123
|
+
# Find by URL
|
|
1124
|
+
self.queue_idx = next(i for i, x in enumerate(self.queue) if x['url'] == item['url'])
|
|
1125
|
+
except StopIteration:
|
|
1126
|
+
self.queue_idx = -1
|
|
1127
|
+
self.queue = [] # Should not happen if item came from list
|
|
1128
|
+
|
|
1129
|
+
start_pos = 0
|
|
1130
|
+
if 'url' in item:
|
|
1131
|
+
saved = self.dm.get_progress(item['url'])
|
|
1132
|
+
if saved > 10:
|
|
1133
|
+
# Autoskip resume prompt in Autoplay (interactive=False)
|
|
1134
|
+
if interactive:
|
|
1135
|
+
if self.ask_resume(saved, item.get('title', 'Unknown')): start_pos = saved
|
|
1136
|
+
else:
|
|
1137
|
+
start_pos = 0
|
|
1138
|
+
|
|
1139
|
+
self.player.play(item['url'], start_pos)
|
|
1140
|
+
|
|
1141
|
+
# Re-apply EQ logic (double check: mpv restart wipes af property?)
|
|
1142
|
+
# Yes, play() might restart mpv if socket fails.
|
|
1143
|
+
# But set_equalizer uses set_property which works via IPC *after* launch.
|
|
1144
|
+
# However, play() sends "loadfile" if existing, or restarts.
|
|
1145
|
+
# If restarted, we must re-apply EQ.
|
|
1146
|
+
# Ideally Player.play should handle restoring EQ state or we rely on MyTunesApp to do it.
|
|
1147
|
+
# But for now, let's just re-apply it here after a tiny delay or ensure Player persists it?
|
|
1148
|
+
# Actually Player doesn't persist state. MyTunesApp drives it.
|
|
1149
|
+
# So calling set_equalizer *after* play is correct.
|
|
1150
|
+
|
|
1151
|
+
# Player.play() handles loading the file.
|
|
1152
|
+
# Since we set "af" via IPC AFTER play() starts in some logic,
|
|
1153
|
+
# but here we set it before/during. set_property works anytime.
|
|
1154
|
+
|
|
1155
|
+
# Reset state for new track
|
|
1156
|
+
self.playback_time = start_pos
|
|
1157
|
+
self.playback_duration = 0
|
|
1158
|
+
self.is_paused = False
|
|
1159
|
+
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
1160
|
+
h, w = self.stdscr.getmaxyx()
|
|
1161
|
+
box_h, box_w = 8, 60
|
|
1162
|
+
box_y, box_x = (h - box_h) // 2, (w - box_w) // 2
|
|
1163
|
+
|
|
807
1164
|
def ask_resume(self, saved_time, track_title):
|
|
808
1165
|
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
809
1166
|
h, w = self.stdscr.getmaxyx()
|
|
@@ -876,7 +1233,7 @@ class MyTunesApp:
|
|
|
876
1233
|
|
|
877
1234
|
def open_browser(self, url, app_mode=False):
|
|
878
1235
|
"""Open browser using detached subprocess to prevent TUI freezing."""
|
|
879
|
-
self.
|
|
1236
|
+
self.show_feedback(f"🌐 Opening Link: {url[:30]}...")
|
|
880
1237
|
|
|
881
1238
|
def run_open():
|
|
882
1239
|
try:
|
|
@@ -920,13 +1277,13 @@ class MyTunesApp:
|
|
|
920
1277
|
subprocess.Popen(['xdg-open', url], **popen_kwargs)
|
|
921
1278
|
|
|
922
1279
|
# Feedback logic: Success message then auto-clear
|
|
923
|
-
self.
|
|
1280
|
+
self.show_feedback("✅ Browser Launched! (Check Browser)")
|
|
924
1281
|
time.sleep(2.5)
|
|
925
|
-
if "Launched!" in self.status_msg:
|
|
926
|
-
|
|
1282
|
+
# if "Launched!" in self.status_msg: # Logic changed, feedback auto-clears
|
|
1283
|
+
# self.status_msg = ""
|
|
927
1284
|
except Exception as e:
|
|
928
1285
|
# Log error silently to TUI status
|
|
929
|
-
self.
|
|
1286
|
+
self.show_feedback(f"❌ Browser Error: {str(e)[:20]}")
|
|
930
1287
|
|
|
931
1288
|
# Still execute Popen in a thread to be extra safe,
|
|
932
1289
|
# but Popen itself is now detached and redirected.
|
|
@@ -1011,47 +1368,13 @@ class MyTunesApp:
|
|
|
1011
1368
|
self.lang = "en" if self.lang == "ko" else "ko"
|
|
1012
1369
|
self.dm.data["language"] = self.lang
|
|
1013
1370
|
self.dm.save_data()
|
|
1014
|
-
self.
|
|
1371
|
+
self.show_feedback("Language Switched / 언어 변경됨") # Clear stale messages on language switch
|
|
1015
1372
|
elif item["id"] == "quit": self.running = False
|
|
1016
1373
|
else:
|
|
1017
1374
|
self.play_music(item, interactive=True)
|
|
1018
1375
|
|
|
1019
1376
|
|
|
1020
|
-
|
|
1021
|
-
if not item.get("url"): return # Guard against dummy items
|
|
1022
|
-
|
|
1023
|
-
self.current_track = item
|
|
1024
|
-
self.dm.add_history(item)
|
|
1025
|
-
|
|
1026
|
-
# Queue Management
|
|
1027
|
-
if not preserve_queue:
|
|
1028
|
-
# New Queue Context from current view
|
|
1029
|
-
current_list = self.get_current_list()
|
|
1030
|
-
# Copy list to queue (Filter only playable items)
|
|
1031
|
-
self.queue = [i for i in current_list if i.get("url")]
|
|
1032
|
-
# Find index in queue
|
|
1033
|
-
try:
|
|
1034
|
-
# Find by URL
|
|
1035
|
-
self.queue_idx = next(i for i, x in enumerate(self.queue) if x['url'] == item['url'])
|
|
1036
|
-
except StopIteration:
|
|
1037
|
-
self.queue_idx = -1
|
|
1038
|
-
self.queue = [] # Should not happen if item came from list
|
|
1039
|
-
|
|
1040
|
-
start_pos = 0
|
|
1041
|
-
if 'url' in item:
|
|
1042
|
-
saved = self.dm.get_progress(item['url'])
|
|
1043
|
-
if saved > 10:
|
|
1044
|
-
# Autoskip resume prompt in Autoplay (interactive=False)
|
|
1045
|
-
if interactive:
|
|
1046
|
-
if self.ask_resume(saved, item.get('title', 'Unknown')): start_pos = saved
|
|
1047
|
-
else:
|
|
1048
|
-
start_pos = 0
|
|
1049
|
-
|
|
1050
|
-
self.player.play(item['url'], start_pos)
|
|
1051
|
-
# Reset state for new track
|
|
1052
|
-
self.playback_time = start_pos
|
|
1053
|
-
self.playback_duration = 0
|
|
1054
|
-
self.is_paused = False
|
|
1377
|
+
|
|
1055
1378
|
|
|
1056
1379
|
def input_dialog(self, title, prompt):
|
|
1057
1380
|
"""Show a centered input dialog with robust byte-level handling (Fixes Double Enter)."""
|
|
@@ -1157,7 +1480,7 @@ class MyTunesApp:
|
|
|
1157
1480
|
self.scroll_offset = 0
|
|
1158
1481
|
if self.view_stack[-1] != "search":
|
|
1159
1482
|
self.view_stack.append("search")
|
|
1160
|
-
self.
|
|
1483
|
+
self.set_view_status("") # Clear "List is empty" etc.
|
|
1161
1484
|
self.draw()
|
|
1162
1485
|
|
|
1163
1486
|
query = self.input_dialog(self.t("search_label"), self.t("search_prompt"))
|
|
@@ -1169,7 +1492,7 @@ class MyTunesApp:
|
|
|
1169
1492
|
# I should check if it's possible to distinguish.
|
|
1170
1493
|
|
|
1171
1494
|
if query:
|
|
1172
|
-
self.
|
|
1495
|
+
self.show_feedback(self.t("searching"))
|
|
1173
1496
|
self.draw()
|
|
1174
1497
|
# v2.0.0 Refactor: Threaded Search
|
|
1175
1498
|
threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
|
|
@@ -1188,7 +1511,7 @@ class MyTunesApp:
|
|
|
1188
1511
|
# self.player.loading = True
|
|
1189
1512
|
|
|
1190
1513
|
self.current_search_query = query
|
|
1191
|
-
self.
|
|
1514
|
+
self.set_view_status(self.t("searching")) # Persist while threading? Or feedback? Use View Status for async wait
|
|
1192
1515
|
|
|
1193
1516
|
# Resolve yt-dlp path
|
|
1194
1517
|
yt_dlp_cmd = "yt-dlp"
|
|
@@ -1224,7 +1547,15 @@ class MyTunesApp:
|
|
|
1224
1547
|
# Dedup Check
|
|
1225
1548
|
if url not in seen_urls:
|
|
1226
1549
|
seen_urls.add(url)
|
|
1227
|
-
|
|
1550
|
+
# Extract Channel/Author
|
|
1551
|
+
channel = d.get("uploader") or d.get("channel") or d.get("uploader_id") or "Unknown"
|
|
1552
|
+
new.append({
|
|
1553
|
+
"title": d.get("title", "Unknown"),
|
|
1554
|
+
"url": url,
|
|
1555
|
+
"duration": dur_str,
|
|
1556
|
+
"author": channel,
|
|
1557
|
+
"channel": channel
|
|
1558
|
+
})
|
|
1228
1559
|
except: pass
|
|
1229
1560
|
|
|
1230
1561
|
# Enforce hard limit
|
|
@@ -1239,11 +1570,11 @@ class MyTunesApp:
|
|
|
1239
1570
|
# SAVE to History
|
|
1240
1571
|
self.dm.add_search_results(new)
|
|
1241
1572
|
|
|
1242
|
-
self.
|
|
1573
|
+
self.set_view_status(f"Search Done. ({len(new)} results)")
|
|
1243
1574
|
else:
|
|
1244
|
-
self.
|
|
1575
|
+
self.set_view_status(self.t("no_results"))
|
|
1245
1576
|
|
|
1246
|
-
except Exception as e: self.
|
|
1577
|
+
except Exception as e: self.show_feedback(f"Error: {e}")
|
|
1247
1578
|
finally:
|
|
1248
1579
|
self.player.loading = False
|
|
1249
1580
|
|
|
@@ -1293,11 +1624,47 @@ class MyTunesApp:
|
|
|
1293
1624
|
bar_str = f"[{'='*fill_w}{'-'*(bar_w-fill_w)}] {time_str}"
|
|
1294
1625
|
self.stdscr.addstr(h - 4, 2, bar_str, curses.color_pair(3))
|
|
1295
1626
|
|
|
1296
|
-
# Footer Line 2: Song Title
|
|
1627
|
+
# Footer Line 2: Song Title + EQ Status (Right Aligned)
|
|
1297
1628
|
if self.current_track:
|
|
1298
1629
|
status_icon = "❚❚" if self.is_paused else "▶"
|
|
1299
|
-
|
|
1300
|
-
|
|
1630
|
+
|
|
1631
|
+
# Prepare EQ Info for Title Line
|
|
1632
|
+
current_eq = EQUALIZER_KEYS[self.current_eq_index]
|
|
1633
|
+
|
|
1634
|
+
# Show effective EQ with Mode Indicator
|
|
1635
|
+
# Auto Mode: "🎚 Auto: Jazz"
|
|
1636
|
+
# Manual Mode: "🎚 EQ: Pop"
|
|
1637
|
+
if current_eq == "Auto":
|
|
1638
|
+
eq_display = f"🎚 Auto: {self.auto_preset_name}"
|
|
1639
|
+
else:
|
|
1640
|
+
eq_display = f"🎚 EQ: {current_eq}"
|
|
1641
|
+
|
|
1642
|
+
eq_info = f" [{eq_display}]" # Right side content
|
|
1643
|
+
|
|
1644
|
+
# Calculate space for Title
|
|
1645
|
+
# Total Width - margins(2) - Icon(2) - Space(1) - EQ Info - Branding(Maybe separate line, but here we just need fit)
|
|
1646
|
+
# Actually Title line is separate from Branding line.
|
|
1647
|
+
# w - 2 (left) - 2 (icon) - len(eq_info)
|
|
1648
|
+
|
|
1649
|
+
avail_w = w - 6 - self.get_display_width(eq_info)
|
|
1650
|
+
|
|
1651
|
+
song_title = self.current_track['title']
|
|
1652
|
+
# Append Channel check? May make title too long, prioritize Title + EQ
|
|
1653
|
+
ch_name = self.current_track.get('channel') or self.current_track.get('author')
|
|
1654
|
+
if ch_name:
|
|
1655
|
+
song_title += f" [{ch_name}]" # Append content first
|
|
1656
|
+
|
|
1657
|
+
trunc_title = self.truncate(song_title, avail_w)
|
|
1658
|
+
|
|
1659
|
+
# Draw Title
|
|
1660
|
+
self.stdscr.addstr(h - 3, 2, f"{status_icon} {trunc_title}", curses.color_pair(2))
|
|
1661
|
+
|
|
1662
|
+
# Draw EQ Info (Right Aligned on same line)
|
|
1663
|
+
eq_x = 2 + self.get_display_width(f"{status_icon} {trunc_title}")
|
|
1664
|
+
# Or strictly right aligned?
|
|
1665
|
+
eq_x_strict = w - 2 - self.get_display_width(eq_info)
|
|
1666
|
+
self.stdscr.addstr(h - 3, eq_x_strict, eq_info, curses.color_pair(3) | curses.A_BOLD)
|
|
1667
|
+
|
|
1301
1668
|
else:
|
|
1302
1669
|
self.stdscr.addstr(h - 3, 2, self.t("stopped"), curses.color_pair(1))
|
|
1303
1670
|
|
|
@@ -1308,22 +1675,31 @@ class MyTunesApp:
|
|
|
1308
1675
|
# Draw Branding always - Bright/Bold White
|
|
1309
1676
|
self.stdscr.addstr(h - 2, branding_x, branding, curses.color_pair(1) | curses.A_BOLD)
|
|
1310
1677
|
|
|
1311
|
-
# Draw Status Msg
|
|
1678
|
+
# Draw Status Msg (Priority Logic)
|
|
1679
|
+
displayed_msg = ""
|
|
1680
|
+
attr = curses.A_NORMAL
|
|
1681
|
+
|
|
1682
|
+
# 1. Loading
|
|
1312
1683
|
if self.player.loading:
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1684
|
+
displayed_msg = "⏳ Loading..."
|
|
1685
|
+
attr = curses.color_pair(6) | curses.A_BLINK
|
|
1686
|
+
|
|
1687
|
+
# 2. Transient Feedback (Volume, Keypress, Errors)
|
|
1688
|
+
elif time.time() < self.feedback_expiry and self.feedback_msg:
|
|
1689
|
+
displayed_msg = f"📢 {self.feedback_msg}"
|
|
1690
|
+
attr = curses.color_pair(3) | curses.A_BOLD
|
|
1319
1691
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1692
|
+
# 3. Persistent View Context (Favorites path, History count, etc.)
|
|
1693
|
+
elif self.view_msg:
|
|
1694
|
+
displayed_msg = f"ℹ️ {self.view_msg}"
|
|
1695
|
+
attr = curses.color_pair(1)
|
|
1696
|
+
|
|
1697
|
+
# 4. Fallback/Idle (Empty)
|
|
1698
|
+
|
|
1699
|
+
if displayed_msg:
|
|
1700
|
+
avail_w = branding_x - 4
|
|
1701
|
+
if avail_w > 5:
|
|
1702
|
+
self.stdscr.addstr(h - 2, 2, self.truncate(displayed_msg, avail_w), attr)
|
|
1327
1703
|
|
|
1328
1704
|
# List Area (Remaining Middle)
|
|
1329
1705
|
list_top = 4
|
|
@@ -1363,6 +1739,25 @@ class MyTunesApp:
|
|
|
1363
1739
|
|
|
1364
1740
|
title_txt = self.truncate(item.get('title',''), avail_w)
|
|
1365
1741
|
|
|
1742
|
+
# Channel Name Logic (Smart Display)
|
|
1743
|
+
channel_txt = ""
|
|
1744
|
+
box_w = 0
|
|
1745
|
+
if w > 80: # Only show if screen is wide enough
|
|
1746
|
+
raw_ch = item.get('channel') or item.get('author') or ""
|
|
1747
|
+
if raw_ch:
|
|
1748
|
+
# Max channel width: 20 or 25% of width
|
|
1749
|
+
max_ch_w = min(20, w // 4)
|
|
1750
|
+
if len(raw_ch) > max_ch_w:
|
|
1751
|
+
raw_ch = raw_ch[:max_ch_w-1] + "…"
|
|
1752
|
+
channel_txt = f"[{raw_ch}]"
|
|
1753
|
+
box_w = len(channel_txt) + 1 # +1 padding
|
|
1754
|
+
|
|
1755
|
+
# Refine Title Width based on Channel presence
|
|
1756
|
+
real_avail_w = avail_w - box_w
|
|
1757
|
+
if real_avail_w < 5: real_avail_w = 5
|
|
1758
|
+
|
|
1759
|
+
title_txt = self.truncate(item.get('title',''), real_avail_w)
|
|
1760
|
+
|
|
1366
1761
|
try:
|
|
1367
1762
|
curr_x = 2
|
|
1368
1763
|
# Base Style
|
|
@@ -1374,17 +1769,15 @@ class MyTunesApp:
|
|
|
1374
1769
|
base_style = curses.A_NORMAL
|
|
1375
1770
|
|
|
1376
1771
|
# 1. Prefix
|
|
1377
|
-
# If selected, base_style is Blue/White. If playing(unselected), Green.
|
|
1378
1772
|
self.stdscr.addstr(y_pos, curr_x, prefix, base_style)
|
|
1379
1773
|
curr_x += len(prefix)
|
|
1380
1774
|
|
|
1381
|
-
# 2. Play Icon
|
|
1382
|
-
# base_style already covers Green if playing and not selected.
|
|
1775
|
+
# 2. Play Icon
|
|
1383
1776
|
if chk_icon:
|
|
1384
1777
|
self.stdscr.addstr(y_pos, curr_x, chk_icon, base_style)
|
|
1385
1778
|
curr_x += len(chk_icon)
|
|
1386
1779
|
|
|
1387
|
-
# 3. Fav Icon
|
|
1780
|
+
# 3. Fav Icon
|
|
1388
1781
|
f_style = base_style
|
|
1389
1782
|
if fav_icon and not is_sel: f_style = curses.color_pair(3) | curses.A_BOLD
|
|
1390
1783
|
if fav_icon:
|
|
@@ -1395,15 +1788,15 @@ class MyTunesApp:
|
|
|
1395
1788
|
self.stdscr.addstr(y_pos, curr_x, title_txt, base_style)
|
|
1396
1789
|
curr_x += self.get_display_width(title_txt)
|
|
1397
1790
|
|
|
1398
|
-
# 5.
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
# 6. Duration
|
|
1791
|
+
# 5. Channel Info (Right aligned relative to title space or fixed?)
|
|
1792
|
+
if channel_txt:
|
|
1793
|
+
self.stdscr.addstr(y_pos, curr_x + 1, channel_txt, base_style | curses.A_DIM)
|
|
1794
|
+
# No update to curr_x needed for duration anchor, but good for safety
|
|
1795
|
+
|
|
1796
|
+
# 6. Duration (Right Anchored)
|
|
1405
1797
|
if dur_txt:
|
|
1406
|
-
|
|
1798
|
+
dur_x = w - 2 - len(dur_txt)
|
|
1799
|
+
self.stdscr.addstr(y_pos, dur_x, dur_txt, base_style)
|
|
1407
1800
|
|
|
1408
1801
|
except: pass
|
|
1409
1802
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mytunes-pro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
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
|
|
@@ -18,14 +18,14 @@ Requires-Dist: yt-dlp
|
|
|
18
18
|
Requires-Dist: pusher
|
|
19
19
|
Dynamic: license-file
|
|
20
20
|
|
|
21
|
-
# 🎵 MyTunes Pro - Professional TUI Edition v2.0.
|
|
21
|
+
# 🎵 MyTunes Pro - Professional TUI Edition v2.0.7
|
|
22
22
|
|
|
23
|
-
## 🚀 Terminal-based Media Workflow Experiment v2.0.
|
|
23
|
+
## 🚀 Terminal-based Media Workflow Experiment v2.0.7
|
|
24
24
|
|
|
25
25
|
> [!IMPORTANT]
|
|
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.
|
|
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.
|
|
29
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.
|
|
30
30
|
|
|
31
31
|
MyTunes Pro is a developer-focused **CLI Media Tool** for experimenting with terminal-based media workflows.
|
|
@@ -39,6 +39,8 @@ leveraging the `mpv` engine for local media processing and playback.
|
|
|
39
39
|

|
|
40
40
|

|
|
41
41
|
|
|
42
|
+
---
|
|
43
|
+
|
|
42
44
|
## 📸 Screenshots
|
|
43
45
|
| | |
|
|
44
46
|
| :---: | :---: |
|
|
@@ -68,6 +70,180 @@ leveraging the `mpv` engine for local media processing and playback.
|
|
|
68
70
|
|
|
69
71
|
---
|
|
70
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
|
+
| **`E`** | **Equalizer** | Cycle EQ presets (Auto/Flat/Pop/Rock/Jazz/etc.) |
|
|
191
|
+
| **`6`** | **Back** | Go to previous screen (Same as `Q`, `h`) |
|
|
192
|
+
| **`L`** | **Forward** | Go forward to previous screen (`Right Arrow`) |
|
|
193
|
+
| **`ESC`** | **Background** | **Exit without stopping music** (Background Play) |
|
|
194
|
+
|
|
195
|
+
### 🧭 Basic Navigation
|
|
196
|
+
| Key | Action |
|
|
197
|
+
| :--- | :--- |
|
|
198
|
+
| `↑` / `↓` / `k` / `j` | Move selection Up/Down (Vim keys supported) |
|
|
199
|
+
| `Enter` / `l` | **Select / Play** |
|
|
200
|
+
| `Space` | Play / Pause |
|
|
201
|
+
| `-` / `+` | **Volume Control** |
|
|
202
|
+
| `,` / `.` | Rewind / Forward 10s |
|
|
203
|
+
| `<` / `>` | Rewind / Forward 30s (Shift) |
|
|
204
|
+
| `Backspace` / `h` / `q` | Go Back / Clear Search |
|
|
205
|
+
| `L` | **Go Forward** |
|
|
206
|
+
| `/` | **Search** (Vim Style) |
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 📂 Data Storage
|
|
211
|
+
- Favorites and playback history are permanently saved in `~/.pymusic_data.json` in your home directory.
|
|
212
|
+
- Data is preserved even after restarting the program.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
# 🎵 MyTunes Pro (Experimental Media Tool - KR)
|
|
218
|
+
|
|
219
|
+
## 🚀 터미널 기반 미디어 워크플로우 실험 v2.0.7
|
|
220
|
+
|
|
221
|
+
> [!IMPORTANT]
|
|
222
|
+
> **법적 면책 고지:** 본 프로젝트는 개발자 교육 및 연구를 목적으로 하는 개인적, 비상업적 실험입니다.
|
|
223
|
+
> 본 소프트웨어는 어떠한 미디어 콘텐츠도 직접 호스팅하거나 배포하지 않습니다.
|
|
224
|
+
> 모든 미디어 소스는 사용자의 로컬 환경에서 직접 구성되고 접근되며, 사용자는 외부 플랫폼의 이용 약관을 준수할 책임이 있습니다.
|
|
225
|
+
|
|
226
|
+
MyTunes Pro는 개발자를 위해 설계된 **CLI 미디어 실험 도구**입니다.
|
|
227
|
+
Python `curses` 라이브러리를 통해 터미널 환경에서 미디어 URL을 로드하고 관리하며,
|
|
228
|
+
사용자가 설치한 `mpv` 등의 외부 도구와 연동하여 미디어 워크플로우를 테스트할 수 있습니다.
|
|
229
|
+
|
|
230
|
+
## ✨ 주요 특징
|
|
231
|
+
|
|
232
|
+
- **미디어 핸들링**: 외부 추출 도구를 사용한 미디어 URL 로드 및 처리 지원.
|
|
233
|
+
- **TUI 워크플로우**: `curses` 라이브러리 기반의 효율적인 터미널 인터페이스.
|
|
234
|
+
- **작업 유지**: 순차적 미디어 로딩 및 마지막 작업 상태 복원 기능.
|
|
235
|
+
- **환경 연동**: 사용자에 의해 구성된 외부 미디어 도구와의 연동 지원. (본 소프트웨어는 외부 도구를 포함하여 배포하지 않습니다.)
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 📸 스크린샷 (Screenshots)
|
|
240
|
+
| | |
|
|
241
|
+
| :---: | :---: |
|
|
242
|
+
|  |  |
|
|
243
|
+
|  |  |
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
71
247
|
## 🚀 빠른 설치 (Quick Install)
|
|
72
248
|
|
|
73
249
|
최신 macOS/Linux 시스템(PEP 668)에서는 **`pipx`** 사용을 강력히 권장합니다.
|
|
@@ -129,12 +305,12 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
129
305
|
wsl --install -d Debian
|
|
130
306
|
```
|
|
131
307
|
|
|
132
|
-
|
|
308
|
+
2. **필수 도구 설치**:
|
|
133
309
|
```bash
|
|
134
310
|
sudo apt update && sudo apt install mpv python3-pip pipx -y
|
|
135
311
|
```
|
|
136
312
|
|
|
137
|
-
|
|
313
|
+
3. **MyTunes 설치**:
|
|
138
314
|
```bash
|
|
139
315
|
pipx install mytunes-pro
|
|
140
316
|
pipx ensurepath
|
|
@@ -185,6 +361,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
185
361
|
| **`+`** | **볼륨 UP** | 볼륨 +5% (단축키 `=`와 동일) |
|
|
186
362
|
| **`-`** | **볼륨 DOWN** | 볼륨 -5% (단축키 `_`와 동일) |
|
|
187
363
|
| **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
|
|
364
|
+
| **`E`** | **이퀄라이저** | EQ 프리셋 전환 (Auto/Flat/Pop/Rock/Jazz 등) |
|
|
188
365
|
| **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
|
|
189
366
|
| **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
|
|
190
367
|
| **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
|
|
@@ -211,31 +388,18 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
|
|
|
211
388
|
---
|
|
212
389
|
---
|
|
213
390
|
|
|
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
|
-
- **환경 연동**: 사용자에 의해 구성된 외부 미디어 도구와의 연동 지원. (본 소프트웨어는 외부 도구를 포함하여 배포하지 않습니다.)
|
|
391
|
+
## 🔄 Changelog
|
|
235
392
|
|
|
236
|
-
|
|
393
|
+
### v2.0.7 (2026-02-02)
|
|
394
|
+
- **Performance Optimization**: Improved keyboard responsiveness on Windows/WSL by implementing EQ detection caching.
|
|
395
|
+
- **Data Management**: Limited resume data to 500 entries with automatic FIFO cleanup to prevent JSON bloat.
|
|
396
|
+
- **Cache System**: Added 200-entry EQ genre cache to skip redundant keyword matching for repeated tracks.
|
|
237
397
|
|
|
238
|
-
|
|
398
|
+
### v2.0.6 (2026-02-02)
|
|
399
|
+
- **10-Band Equalizer**: Added professional-grade 10-band EQ with presets (Flat, Pop, Rock, Jazz, Classical, Full Bass, Dance, Club, Live, Soft).
|
|
400
|
+
- **Auto EQ Detection**: Intelligent genre detection from track title/channel info automatically applies optimal EQ preset.
|
|
401
|
+
- **Keyboard Shortcut**: Press `E` to cycle through EQ presets in real-time without interrupting playback.
|
|
402
|
+
- **Multilingual Genre Keywords**: Auto EQ supports genre detection in 12 languages including Korean, Japanese, Chinese, Spanish, and more.
|
|
239
403
|
|
|
240
404
|
### v2.0.5 (2026-02-01)
|
|
241
405
|
- **Input Feedback Refinement**: Transitioned from blinking warnings to a static Bold Yellow status message for better accessibility and premium feel.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mytunes/app.py,sha256=TMTcxZyBga5IYkJQPRiDKpZ6nEn5OCd62jySxTfmMd8,81539
|
|
3
|
+
mytunes_pro-2.0.7.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
|
|
4
|
+
mytunes_pro-2.0.7.dist-info/METADATA,sha256=zXK3JFIv8PpoLquQF0Xh17ebRmMCSMSDCJP0mUHEtvk,28648
|
|
5
|
+
mytunes_pro-2.0.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
mytunes_pro-2.0.7.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
|
|
7
|
+
mytunes_pro-2.0.7.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
|
|
8
|
+
mytunes_pro-2.0.7.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
mytunes/app.py,sha256=-vJSyC0gF0rj0Q3LkJWwlDH0c75OHZRxQ3l4Nxj19Ps,59412
|
|
3
|
-
mytunes_pro-2.0.5.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
|
|
4
|
-
mytunes_pro-2.0.5.dist-info/METADATA,sha256=GelVLtHE3uFp4s76DylYNpNFF4lgz8hY4hokILwIE6g,23172
|
|
5
|
-
mytunes_pro-2.0.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
-
mytunes_pro-2.0.5.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
|
|
7
|
-
mytunes_pro-2.0.5.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
|
|
8
|
-
mytunes_pro-2.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|