flow-twinx 0.4.2__tar.gz → 0.4.3__tar.gz

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.
Files changed (34) hide show
  1. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/PKG-INFO +11 -10
  2. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/README.md +10 -9
  3. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Offline/commands.py +35 -34
  4. flow_twinx-0.4.3/flow_twinx/Offline/player.py +170 -0
  5. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Online/commands.py +137 -23
  6. flow_twinx-0.4.3/flow_twinx/Online/player.py +274 -0
  7. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Online/savan.py +17 -1
  8. flow_twinx-0.4.3/flow_twinx/Online/youtube.py +151 -0
  9. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/config.py +46 -2
  10. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/help_detail.py +11 -8
  11. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/main.py +16 -1
  12. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/tui.py +7 -3
  13. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx.egg-info/PKG-INFO +11 -10
  14. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/pyproject.toml +1 -1
  15. flow_twinx-0.4.2/flow_twinx/Offline/player.py +0 -81
  16. flow_twinx-0.4.2/flow_twinx/Online/player.py +0 -140
  17. flow_twinx-0.4.2/flow_twinx/Online/youtube.py +0 -85
  18. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Offline/__init__.py +0 -0
  19. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Offline/file.py +0 -0
  20. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Offline/youtube.py +0 -0
  21. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/Online/__init__.py +0 -0
  22. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/__init__.py +0 -0
  23. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/imports.py +0 -0
  24. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/lyrics.py +0 -0
  25. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/ping.py +0 -0
  26. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/shortcuts.py +0 -0
  27. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx/visualizer.py +0 -0
  28. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx.egg-info/SOURCES.txt +0 -0
  29. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx.egg-info/dependency_links.txt +0 -0
  30. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx.egg-info/entry_points.txt +0 -0
  31. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx.egg-info/requires.txt +0 -0
  32. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/flow_twinx.egg-info/top_level.txt +0 -0
  33. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/requirements.txt +0 -0
  34. {flow_twinx-0.4.2 → flow_twinx-0.4.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flow-twinx
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: A terminal music player with online and offline modes
5
5
  Project-URL: Homepage, https://github.com/philast-015/flow/
6
6
  Project-URL: Issues, https://github.com/philast-015/flow/issues
@@ -73,7 +73,8 @@ uv run main.py
73
73
  | `-kill` | Kill all background VLC processes |
74
74
  | `-i` | Use it in help command to show detailed help |
75
75
  | `-s` | Use it shuffle or play random songs |
76
- | `-i` | Use it to repeat songs no of time [ -t n ] [ -t ] ( n = no of times ) |
76
+ | `-r` | Use it to repeat songs no of time [ -r n ] [ -r ] ( n = no of times ) |
77
+ | `-d` | Use it to download songs |
77
78
 
78
79
  ### Shell Mode
79
80
 
@@ -116,14 +117,6 @@ flow -dl never gonna give you up # dl → download
116
117
  | `help` | Show available commands |
117
118
  | `help -i` | Show available commands with detailed explanation |
118
119
 
119
- ## Configuration
120
-
121
- - Downloads and library are stored in `~/.flow/downloads/`
122
- - Liked songs (online) are saved to `~/.flow/liked.txt`
123
- - Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
124
- - User shortcuts are stored in `~/.flow/shortcuts.json`
125
- - Config file: `~/.flow/config.json`
126
-
127
120
  ### Config Options
128
121
 
129
122
  | Target | Description | Range |
@@ -138,6 +131,14 @@ flow -dl never gonna give you up # dl → download
138
131
 
139
132
  Usage: `config <target> <value>`
140
133
 
134
+ ## Configuration
135
+
136
+ - Downloads and library are stored in `~/.flow/downloads/`
137
+ - Liked songs (online) are saved to `~/.flow/liked.txt`
138
+ - Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
139
+ - User shortcuts are stored in `~/.flow/shortcuts.json`
140
+ - Config file: `~/.flow/config.json`
141
+
141
142
  ## Project Structure
142
143
 
143
144
  ```
@@ -59,7 +59,8 @@ uv run main.py
59
59
  | `-kill` | Kill all background VLC processes |
60
60
  | `-i` | Use it in help command to show detailed help |
61
61
  | `-s` | Use it shuffle or play random songs |
62
- | `-i` | Use it to repeat songs no of time [ -t n ] [ -t ] ( n = no of times ) |
62
+ | `-r` | Use it to repeat songs no of time [ -r n ] [ -r ] ( n = no of times ) |
63
+ | `-d` | Use it to download songs |
63
64
 
64
65
  ### Shell Mode
65
66
 
@@ -102,14 +103,6 @@ flow -dl never gonna give you up # dl → download
102
103
  | `help` | Show available commands |
103
104
  | `help -i` | Show available commands with detailed explanation |
104
105
 
105
- ## Configuration
106
-
107
- - Downloads and library are stored in `~/.flow/downloads/`
108
- - Liked songs (online) are saved to `~/.flow/liked.txt`
109
- - Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
110
- - User shortcuts are stored in `~/.flow/shortcuts.json`
111
- - Config file: `~/.flow/config.json`
112
-
113
106
  ### Config Options
114
107
 
115
108
  | Target | Description | Range |
@@ -124,6 +117,14 @@ flow -dl never gonna give you up # dl → download
124
117
 
125
118
  Usage: `config <target> <value>`
126
119
 
120
+ ## Configuration
121
+
122
+ - Downloads and library are stored in `~/.flow/downloads/`
123
+ - Liked songs (online) are saved to `~/.flow/liked.txt`
124
+ - Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
125
+ - User shortcuts are stored in `~/.flow/shortcuts.json`
126
+ - Config file: `~/.flow/config.json`
127
+
127
128
  ## Project Structure
128
129
 
129
130
  ```
@@ -1,7 +1,8 @@
1
1
  import os
2
2
  import random
3
- from ..imports import config, merge_flags, is_connected
4
- from .. import shortcuts
3
+
4
+ from .. import help_detail, shortcuts
5
+ from ..imports import config, is_connected, merge_flags
5
6
  from . import file as lib
6
7
  from . import player
7
8
 
@@ -31,8 +32,10 @@ def _fork_bg(label):
31
32
  os.dup2(devnull, 2)
32
33
  return True
33
34
 
35
+
34
36
  try:
35
37
  import readline
38
+
36
39
  _HAS_READLINE = True
37
40
  except ImportError:
38
41
  _HAS_READLINE = False
@@ -41,15 +44,15 @@ _last_results = []
41
44
  _last_played = None
42
45
 
43
46
  COMMANDS = {
44
- "play": "Play song(s) from local library | all by play all | and liked",
45
- "search": "Search local music library",
46
- "list": "List local music library",
47
- "like": "Like the currently playing song",
48
- "switch": "Switch to Online mode (checks connection)",
49
- "help": "Show this help message",
50
- "short": "Show/update command shortcuts",
51
- "config": "Change primary/secondary/tertiary colors",
52
- "exit": "Exit Flow",
47
+ "play": "Play song(s) from local library | all by play all | and liked",
48
+ "search": "Search local music library",
49
+ "list": "List local music library",
50
+ "like": "Like the currently playing song",
51
+ "switch": "Switch to Online mode (checks connection)",
52
+ "help": "Show this help message",
53
+ "short": "Show/update command shortcuts",
54
+ "config": "Change primary/secondary/tertiary colors",
55
+ "exit": "Exit Flow",
53
56
  }
54
57
 
55
58
 
@@ -67,10 +70,9 @@ class _Completer:
67
70
  songs = lib.get_song_names()
68
71
  albums = lib.get_album_names()
69
72
  all_names = songs + albums
70
- options = sorted(set(
71
- n for n in all_names
72
- if n.lower().startswith(text.lower())
73
- ))
73
+ options = sorted(
74
+ set(n for n in all_names if n.lower().startswith(text.lower()))
75
+ )
74
76
  else:
75
77
  options = []
76
78
  self.matches = options
@@ -85,12 +87,14 @@ def _setup_completion():
85
87
  return
86
88
  readline.set_completer(_Completer().complete)
87
89
  readline.parse_and_bind("tab: complete")
88
- readline.set_completer_delims(' \t\n;')
90
+ readline.set_completer_delims(" \t\n;")
89
91
 
90
92
 
91
93
  def run(cmd: str, extra: list[str], args):
92
94
  _setup_completion()
93
95
  cmd = shortcuts.resolve(cmd)
96
+ inf = "-i" in extra
97
+ extra = [x for x in extra if x != "-i"]
94
98
  extra, args = merge_flags(extra, args)
95
99
  if cmd == "play":
96
100
  play(extra, args)
@@ -103,7 +107,7 @@ def run(cmd: str, extra: list[str], args):
103
107
  elif cmd == "switch":
104
108
  switch_mode()
105
109
  elif cmd == "help":
106
- show_help()
110
+ show_help(inf)
107
111
  elif cmd == "short":
108
112
  shortcuts.cmd_short(extra, m)
109
113
  elif cmd == "config":
@@ -184,6 +188,7 @@ def _play_liked(args):
184
188
  except KeyboardInterrupt:
185
189
  pass
186
190
 
191
+
187
192
  def _play_all(args):
188
193
  global _last_played
189
194
  all_songs = lib.get_all_songs()
@@ -212,6 +217,7 @@ def _play_all(args):
212
217
  except KeyboardInterrupt:
213
218
  pass
214
219
 
220
+
215
221
  def _play_album(album: str, args):
216
222
  global _last_played
217
223
  songs = lib.get_album_songs(album)
@@ -295,20 +301,15 @@ def switch_mode():
295
301
  e("No internet connection")
296
302
 
297
303
 
298
- def show_help():
299
- print(f"{T}Offline Commands:{R}")
300
- for cmd, desc in COMMANDS.items():
301
- print(f" {T}{cmd:12s}{R} {G}{desc}{R}")
302
- print(f"\n{T}Config:{R}")
303
- print(f" {T}config{R} {G}Configure colors, display mode, bars{R}")
304
- print(f" {G}config help Show all config targets and colors{R}")
305
- print(f" {G}config display Set display: none, bars, lyrics{R}")
306
- print(f" {G}config primary Set primary color (online songs){R}")
307
- print(f" {G}config secondary Set secondary color (offline songs){R}")
308
- print(f" {G}config tertiary Set tertiary color (labels){R}")
309
- print(f" {G}config barwidth Bar count: 4-80 (current: {config.BarWidth}){R}")
310
- print(f" {G}config barheight Bar height: 2-16 (current: {config.BarHeight}){R}")
311
- print(f" {G}config barspacing 0-4, min, fit, max (current: {config.BarSpacing}){R}")
312
- print(f"\n{T}Display Modes:{R}")
313
- print(f" {G}bars{R} Audio-reactive spectrum analyzer (needs audio output){R}")
314
- print(f" {G}none{R} No display during playback{R}")
304
+ def show_help(inf=False):
305
+ if inf:
306
+ print(f"{T}Offline Commands (detailed):{R}")
307
+ for cmd, lines in help_detail.OFFLINE_HELP.items():
308
+ for line in lines:
309
+ print(f" {line}")
310
+ print()
311
+ else:
312
+ print(f"{T}Offline Commands:{R}")
313
+ for cmd, desc in COMMANDS.items():
314
+ print(f" {T}{cmd:12s}{R} {G}{desc}{R}")
315
+ print(f"{G} Use 'help -i' for detailed usage{R}")
@@ -0,0 +1,170 @@
1
+ import errno
2
+ import fcntl
3
+ import os
4
+ import signal
5
+ import sys
6
+ import termios
7
+ import threading
8
+ import time
9
+ import vlc
10
+ from ..imports import config
11
+ from .. import visualizer
12
+
13
+ P = config.Primary
14
+ S = config.Secondary
15
+ T = config.Tertiary
16
+ M = config.Muted
17
+ E = config.Red
18
+ R = config.Reset
19
+
20
+ m = lambda t: print(f"{M}{t}{R}")
21
+ e = lambda t: print(f"{E}{t}{R}")
22
+ i = lambda t: print(f"{P if config.Mode == 'Online' else S}{t}{R}")
23
+ t = lambda t: print(f"{T}{t}{R}")
24
+
25
+ _paused = False
26
+ _player = None
27
+ _original_term = None
28
+
29
+
30
+ def _sigusr1_toggle(sig, frame):
31
+ global _paused
32
+ _paused = not _paused
33
+ if _player:
34
+ _player.pause()
35
+
36
+
37
+ def _setup_pause_input():
38
+ global _original_term
39
+ fd = sys.stdin.fileno()
40
+ try:
41
+ _original_term = termios.tcgetattr(fd)
42
+ new = termios.tcgetattr(fd)
43
+ new[0] &= ~termios.IXON
44
+ new[6][termios.VSUSP] = 0
45
+ termios.tcsetattr(fd, termios.TCSADRAIN, new)
46
+ except (termios.error, OSError):
47
+ _original_term = None
48
+ return
49
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
50
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
51
+ signal.signal(signal.SIGUSR1, _sigusr1_toggle)
52
+ signal.signal(signal.SIGTSTP, signal.SIG_IGN)
53
+ thread = threading.Thread(target=_input_reader, daemon=True)
54
+ thread.start()
55
+
56
+
57
+ def _input_reader():
58
+ fd = sys.stdin.fileno()
59
+ try:
60
+ while True:
61
+ try:
62
+ ch = os.read(fd, 1)
63
+ except OSError as ex:
64
+ if ex.errno == errno.EAGAIN:
65
+ time.sleep(0.05)
66
+ continue
67
+ break
68
+ if ch == b"\x10":
69
+ os.kill(os.getpid(), signal.SIGUSR1)
70
+ except OSError:
71
+ pass
72
+
73
+
74
+ def _restore_pause_input():
75
+ global _original_term
76
+ if _original_term is not None:
77
+ try:
78
+ fd = sys.stdin.fileno()
79
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
80
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
81
+ termios.tcsetattr(fd, termios.TCSADRAIN, _original_term)
82
+ except (termios.error, OSError):
83
+ pass
84
+ _original_term = None
85
+
86
+
87
+ def _flags_str(args):
88
+ if not args:
89
+ return ""
90
+ parts = []
91
+ if getattr(args, "repeat", False):
92
+ count = getattr(args, "repeat_count", 0)
93
+ parts.append(f"[Repeat:{'∞' if count < 0 else count}]")
94
+ if getattr(args, "shuffle", False):
95
+ parts.append("[Shuffle:On]")
96
+ return " ".join(parts)
97
+
98
+
99
+ def _display_loop(player, duration=0):
100
+ global _paused
101
+ display = config.Display
102
+ if display == "none":
103
+ return
104
+
105
+ if display == "bars":
106
+ visualizer.start()
107
+
108
+ start = time.time()
109
+ paused_printed = False
110
+ try:
111
+ while player.get_state() not in (vlc.State.Ended, vlc.State.Error):
112
+ if _paused:
113
+ if not paused_printed:
114
+ sys.stdout.write(f"\r {T}[Paused]{R} ")
115
+ sys.stdout.flush()
116
+ paused_printed = True
117
+ try:
118
+ time.sleep(0.1)
119
+ except OSError:
120
+ pass
121
+ continue
122
+ if paused_printed:
123
+ paused_printed = False
124
+ if display == "bars":
125
+ bar_str = visualizer.render(color=S, reset=R)
126
+ sys.stdout.write(f"\r{bar_str}")
127
+ sys.stdout.flush()
128
+ try:
129
+ time.sleep(0.08)
130
+ except OSError:
131
+ pass
132
+ finally:
133
+ if display == "bars":
134
+ visualizer.stop()
135
+ sys.stdout.write("\r" + " " * 60 + "\r\n")
136
+ sys.stdout.flush()
137
+
138
+
139
+ def play_file(filepath, title, args=None):
140
+ global _player, _paused
141
+ _paused = False
142
+ instance = vlc.Instance("--no-video --quiet")
143
+ _player = instance.media_player_new()
144
+ media = instance.media_new(str(filepath))
145
+ _player.set_media(media)
146
+ _player.play()
147
+ _setup_pause_input()
148
+
149
+ duration = 0
150
+ for _ in range(50):
151
+ duration = _player.get_length() / 1000
152
+ if duration > 0:
153
+ break
154
+ time.sleep(0.1)
155
+ if duration <= 0:
156
+ duration = 0
157
+ dur_min, dur_sec = divmod(int(duration), 60)
158
+ flags = _flags_str(args)
159
+ i(f"\nPlaying : {title}")
160
+ m(f" [{dur_min}:{dur_sec:02d}] {flags}" if flags else f" [{dur_min}:{dur_sec:02d}]")
161
+
162
+ try:
163
+ _display_loop(_player, duration=duration)
164
+ except KeyboardInterrupt:
165
+ _player.stop()
166
+ sys.stdout.write("\n")
167
+ sys.stdout.flush()
168
+ raise
169
+ finally:
170
+ _restore_pause_input()
@@ -1,3 +1,5 @@
1
+ import errno
2
+ import fcntl
1
3
  import os
2
4
  import random
3
5
  import signal
@@ -6,7 +8,9 @@ import termios
6
8
  import threading
7
9
  import time
8
10
 
9
- from .. import shortcuts
11
+ from flow_twinx.config import MAX_SEARCH_RESULTS
12
+
13
+ from .. import help_detail, shortcuts
10
14
  from ..imports import config, merge_flags
11
15
  from . import player, savan, youtube
12
16
 
@@ -72,6 +76,8 @@ COMMANDS = {
72
76
 
73
77
  def run(cmd: str, extra: list[str], args):
74
78
  cmd = shortcuts.resolve(cmd)
79
+ inf = "-i" in extra
80
+ extra = [x for x in extra if x != "-i"]
75
81
  extra, args = merge_flags(extra, args)
76
82
  if cmd == "play":
77
83
  play(extra, args)
@@ -88,7 +94,7 @@ def run(cmd: str, extra: list[str], args):
88
94
  elif cmd == "switch":
89
95
  switch_mode()
90
96
  elif cmd == "help":
91
- show_help()
97
+ show_help(inf)
92
98
  elif cmd in ("radio", "rd"):
93
99
  radio(extra, args)
94
100
  elif cmd == "short":
@@ -116,7 +122,7 @@ def _do_search(query):
116
122
  stop = False
117
123
  t = threading.Thread(target=_spinner, args=(lambda: stop,), daemon=True)
118
124
  t.start()
119
- _last_results = youtube.search(query)
125
+ _last_results = youtube.search(query, MAX_SEARCH_RESULTS)
120
126
  stop = True
121
127
  t.join()
122
128
 
@@ -130,6 +136,10 @@ def play(extra: list[str], args):
130
136
  print("No song specified")
131
137
  return
132
138
 
139
+ if getattr(args, "download", False):
140
+ download(extra)
141
+ return
142
+
133
143
  if arg == "liked":
134
144
  _play_liked(args)
135
145
  return
@@ -332,6 +342,16 @@ def like_track():
332
342
  if not url:
333
343
  e(" No URL for current song")
334
344
  return
345
+ config.dev_print(
346
+ "Like Track",
347
+ {
348
+ "title": title,
349
+ "url": url,
350
+ "video_id": entry.get("id"),
351
+ "webpage_url": entry.get("webpage_url"),
352
+ "original_url": entry.get("original_url"),
353
+ },
354
+ )
335
355
  config.liked_music.parent.mkdir(parents=True, exist_ok=True)
336
356
  existing = (
337
357
  config.liked_music.read_text().strip().splitlines()
@@ -380,6 +400,15 @@ def radio(extra, args):
380
400
  if _radio_tracks and 0 <= idx < len(_radio_tracks):
381
401
  title, vid, dur = _radio_tracks[idx]
382
402
  url = f"https://www.youtube.com/watch?v={vid}"
403
+ config.dev_print(
404
+ "Radio (play by index)",
405
+ {
406
+ "title": title,
407
+ "video_id": vid,
408
+ "url": url,
409
+ "duration": f"{dur}s",
410
+ },
411
+ )
383
412
  entry = youtube.get_entry(url)
384
413
  if getattr(args, "bg", False):
385
414
  if not _fork_bg("Now playing"):
@@ -398,6 +427,15 @@ def radio(extra, args):
398
427
  if _radio_tracks and 0 <= idx < len(_radio_tracks):
399
428
  title, vid, dur = _radio_tracks[idx]
400
429
  url = f"https://www.youtube.com/watch?v={vid}"
430
+ config.dev_print(
431
+ "Radio (play by query+index)",
432
+ {
433
+ "title": title,
434
+ "video_id": vid,
435
+ "url": url,
436
+ "duration": f"{dur}s",
437
+ },
438
+ )
401
439
  entry = youtube.get_entry(url)
402
440
  if getattr(args, "bg", False):
403
441
  if not _fork_bg("Now playing"):
@@ -420,6 +458,23 @@ def radio(extra, args):
420
458
 
421
459
  _radio_tracks = tracks
422
460
 
461
+ if getattr(args, "download", False):
462
+ for idx, (title, vid, dur) in enumerate(_radio_tracks, 1):
463
+ url = f"https://www.youtube.com/watch?v={vid}"
464
+ config.dev_print(
465
+ "Radio Download",
466
+ {
467
+ "title": title,
468
+ "video_id": vid,
469
+ "url": url,
470
+ "duration": f"{dur}s",
471
+ "progress": f"{idx}/{len(_radio_tracks)}",
472
+ },
473
+ )
474
+ filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
475
+ i(f" Downloaded ({idx}/{len(_radio_tracks)}): {title}")
476
+ return
477
+
423
478
  if getattr(args, "shuffle", False):
424
479
  random.shuffle(_radio_tracks)
425
480
 
@@ -433,18 +488,48 @@ def radio(extra, args):
433
488
 
434
489
  old_sigint = signal.signal(signal.SIGINT, _radio_sigint)
435
490
  old_sigquit = signal.signal(signal.SIGQUIT, _radio_sigquit)
491
+ old_sigusr1 = signal.getsignal(signal.SIGUSR1)
436
492
 
437
493
  fd = sys.stdin.fileno()
438
494
  old_term = None
495
+ old_fd_flags = None
439
496
  try:
440
497
  old_term = termios.tcgetattr(fd)
441
498
  new = termios.tcgetattr(fd)
442
499
  new[0] &= ~termios.IXON
443
500
  new[6][termios.VQUIT] = 0x11 # Ctrl+Q -> SIGQUIT
501
+ new[6][termios.VSUSP] = 0 # disable Ctrl+Z suspend
444
502
  termios.tcsetattr(fd, termios.TCSADRAIN, new)
503
+ old_fd_flags = fcntl.fcntl(fd, fcntl.F_GETFL)
504
+ fcntl.fcntl(fd, fcntl.F_SETFL, old_fd_flags | os.O_NONBLOCK)
445
505
  except (termios.error, OSError):
446
506
  pass
447
507
 
508
+ def _radio_sigusr1(sig, frame):
509
+ player._sigusr1_toggle(sig, frame)
510
+
511
+ signal.signal(signal.SIGUSR1, _radio_sigusr1)
512
+ signal.signal(signal.SIGTSTP, signal.SIG_IGN)
513
+ player._radio_active = True
514
+
515
+ def _radio_input_reader():
516
+ try:
517
+ while True:
518
+ try:
519
+ ch = os.read(fd, 1)
520
+ except OSError as ex:
521
+ if ex.errno == errno.EAGAIN:
522
+ time.sleep(0.05)
523
+ continue
524
+ break
525
+ if ch == b"\x10":
526
+ os.kill(os.getpid(), signal.SIGUSR1)
527
+ except OSError:
528
+ pass
529
+
530
+ radio_reader = threading.Thread(target=_radio_input_reader, daemon=True)
531
+ radio_reader.start()
532
+
448
533
  flags = {"quit": lambda: _radio_quit, "skip": lambda: _radio_skip}
449
534
 
450
535
  repeat = getattr(args, "repeat", False)
@@ -457,6 +542,16 @@ def radio(extra, args):
457
542
  while idx < len(_radio_tracks) and not _radio_quit:
458
543
  title, vid, dur = _radio_tracks[idx]
459
544
  url = f"https://www.youtube.com/watch?v={vid}"
545
+ config.dev_print(
546
+ "Radio (playing track)",
547
+ {
548
+ "title": title,
549
+ "video_id": vid,
550
+ "url": url,
551
+ "duration": f"{dur}s",
552
+ "position": f"{idx + 1}/{len(_radio_tracks)}",
553
+ },
554
+ )
460
555
  entry = youtube.get_entry(url)
461
556
  short = _truncate_title(title)
462
557
  mins, secs = divmod(int(dur), 60)
@@ -465,7 +560,7 @@ def radio(extra, args):
465
560
  n_title, n_vid, n_dur = _radio_tracks[idx + 1]
466
561
  n_short = _truncate_title(n_title)
467
562
  n_mins, n_secs = divmod(int(n_dur), 60)
468
- print(f"{T}\n\t⥤ Next: {n_short:30s} {n_mins}:{n_secs:02d}{R}")
563
+ print(f"{T}\n\t[⥤ Next: {n_short:30s} {n_mins}:{n_secs:02d}]{R}")
469
564
 
470
565
  _radio_skip = False
471
566
  player.play_entry(entry, title, args, flags=flags)
@@ -478,10 +573,17 @@ def radio(extra, args):
478
573
  except KeyboardInterrupt:
479
574
  _radio_quit = True
480
575
  finally:
576
+ player._radio_active = False
481
577
  if old_term is not None:
482
- termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
578
+ try:
579
+ if old_fd_flags is not None:
580
+ fcntl.fcntl(fd, fcntl.F_SETFL, old_fd_flags)
581
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
582
+ except (termios.error, OSError):
583
+ pass
483
584
  signal.signal(signal.SIGINT, old_sigint)
484
585
  signal.signal(signal.SIGQUIT, old_sigquit)
586
+ signal.signal(signal.SIGUSR1, old_sigusr1)
485
587
 
486
588
 
487
589
  def download(extra: list[str]):
@@ -502,6 +604,15 @@ def download(extra: list[str]):
502
604
  e(" No URL found for this entry")
503
605
  return
504
606
  title = entry.get("title", "Unknown")
607
+ config.dev_print(
608
+ "Download (by index)",
609
+ {
610
+ "title": title,
611
+ "url": url,
612
+ "video_id": entry.get("id"),
613
+ "output_dir": str(config.DOWNLOAD_DIR),
614
+ },
615
+ )
505
616
  filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
506
617
  i(f" Downloaded: {title} -> {filepath}")
507
618
  else:
@@ -515,6 +626,15 @@ def download(extra: list[str]):
515
626
  e(" No URL found for this entry")
516
627
  return
517
628
  title = entry.get("title", "Unknown")
629
+ config.dev_print(
630
+ "Download (by search)",
631
+ {
632
+ "title": title,
633
+ "url": url,
634
+ "video_id": entry.get("id"),
635
+ "output_dir": str(config.DOWNLOAD_DIR),
636
+ },
637
+ )
518
638
  filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
519
639
  i(f" Downloaded: {title} -> {filepath}")
520
640
 
@@ -523,21 +643,15 @@ def switch_mode():
523
643
  config.Mode = "Offline"
524
644
 
525
645
 
526
- def show_help():
527
- print(f"{T}Online Commands:{R}")
528
- for cmd, desc in COMMANDS.items():
529
- print(f" {T}{cmd:12s}{R} {G}{desc}{R}")
530
- print(f"\n{T}Config:{R}")
531
- print(f" {T}config{R} {G}Configure colors, display mode, bars{R}")
532
- print(f" {G}config help Show all config targets and colors{R}")
533
- print(f" {G}config display Set display: none, bars, lyrics{R}")
534
- print(f" {G}config primary Set primary color (online songs){R}")
535
- print(f" {G}config secondary Set secondary color (offline songs){R}")
536
- print(f" {G}config tertiary Set tertiary color (labels){R}")
537
- print(f" {G}config barwidth Bar count: 4-80 (current: {config.BarWidth}){R}")
538
- print(f" {G}config barheight Bar height: 2-16 (current: {config.BarHeight}){R}")
539
- print(f" {G}config barspacing 0-4, min, fit, max (current: {config.BarSpacing}){R}")
540
- print(f"\n{T}Display Modes:{R}")
541
- print(f" {G}bars{R} Audio-reactive spectrum analyzer (needs audio output){R}")
542
- print(f" {G}lyrics{R} Synced lyrics display with colors{R}")
543
- print(f" {G}none{R} No display during playback{R}")
646
+ def show_help(inf=False):
647
+ if inf:
648
+ print(f"{T}Online Commands (detailed):{R}")
649
+ for cmd, lines in help_detail.ONLINE_HELP.items():
650
+ for line in lines:
651
+ print(f" {line}")
652
+ print()
653
+ else:
654
+ print(f"{T}Online Commands:{R}")
655
+ for cmd, desc in COMMANDS.items():
656
+ print(f" {T}{cmd:12s}{R} {G}{desc}{R}")
657
+ print(f"{G} Use 'help -i' for detailed usage{R}")