lysn 0.1.4__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.
lysn-0.1.4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wattox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
lysn-0.1.4/PKG-INFO ADDED
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: lysn
3
+ Version: 0.1.4
4
+ Summary: TUI music player
5
+ Author: Wattox00
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: textual>=0.50.0
11
+ Requires-Dist: python-vlc
12
+ Requires-Dist: yt-dlp
13
+ Dynamic: license-file
14
+
15
+ # Lysn - cli_music_player
16
+ A Cli music player mainly for Linux
lysn-0.1.4/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # Lysn - cli_music_player
2
+ A Cli music player mainly for Linux
File without changes
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ import time
3
+ import logging
4
+ from pathlib import Path
5
+ from yt_dlp import YoutubeDL
6
+
7
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")
8
+ logger = logging.getLogger(__name__)
9
+
10
+ BASE_DIR = Path.home() / "Music"
11
+
12
+ def load_set(path: Path):
13
+ if path.exists():
14
+ with path.open() as f:
15
+ return set(x.strip() for x in f if x.strip())
16
+ return set()
17
+
18
+ def append_lines(path: Path, lines):
19
+ with path.open("a") as f:
20
+ for line in lines:
21
+ f.write(line + "\n")
22
+
23
+ def extract_entries(url):
24
+ ydl_opts = {
25
+ "quiet": True,
26
+ "extract_flat": True,
27
+ "skip_download": True,
28
+ }
29
+
30
+ with YoutubeDL(ydl_opts) as ydl:
31
+ info = ydl.extract_info(url, download=False)
32
+
33
+ entries = info.get("entries", [])
34
+ urls = []
35
+
36
+ for e in entries:
37
+ if not e:
38
+ continue
39
+ webpage_url = e.get("url") or e.get("webpage_url")
40
+ if webpage_url:
41
+ urls.append(webpage_url)
42
+
43
+ return urls
44
+
45
+ def likes_url(username: str) -> str:
46
+ return f"https://soundcloud.com/{username}/likes"
47
+
48
+ def playlist_url(username: str, set_name: str, is_user_playlist: bool = False) -> str:
49
+ return f"https://soundcloud.com/{username}/sets/{set_name}"
50
+
51
+ def extract_likes(username: str):
52
+ return extract_entries(likes_url(username))
53
+
54
+ def extract_playlist(username: str, set_name: str, is_user_playlist: bool = False):
55
+ return extract_entries(playlist_url(username, set_name, is_user_playlist))
56
+
57
+ def get_folder_name(username: str, is_likes: bool, set_name: str = None):
58
+ if is_likes:
59
+ return f"{username}-likes"
60
+ return set_name
61
+
62
+ def download_urls(urls, folder_name: str):
63
+ target_dir = BASE_DIR / folder_name
64
+ target_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ downloaded_file = target_dir / "downloaded.txt"
67
+ ignored_file = target_dir / "ignored.txt"
68
+ errors_file = target_dir / "errors.txt"
69
+
70
+ downloaded = load_set(downloaded_file)
71
+ ignored = load_set(ignored_file)
72
+ seen = downloaded | ignored
73
+
74
+ urls = [u for u in urls if u not in seen]
75
+
76
+ if not urls:
77
+ logger.info("No new tracks")
78
+ return
79
+
80
+ ydl_opts = {
81
+ "outtmpl": str(target_dir / "%(uploader)s - %(title)s"),
82
+ "format": "bestaudio/best",
83
+ "quiet": True,
84
+ "noplaylist": True,
85
+ }
86
+
87
+ success = []
88
+ failed = []
89
+
90
+ with YoutubeDL(ydl_opts) as ydl:
91
+ for i, url in enumerate(urls, 1):
92
+ logger.info(f"{i}/{len(urls)} downloading")
93
+ try:
94
+ ydl.download([url])
95
+ success.append(url)
96
+ except Exception as e:
97
+ failed.append((url, str(e)))
98
+ time.sleep(1)
99
+
100
+ append_lines(downloaded_file, success)
101
+
102
+ if failed:
103
+ with errors_file.open("a") as f:
104
+ f.write(f"\n\nRun @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
105
+ for url, err in failed:
106
+ f.write(f"{url}\n{err}\n\n")
107
+
108
+
109
+ def run_likes(username: str):
110
+ logger.info(f"Fetching likes for: {username}")
111
+ urls = extract_likes(username)
112
+ folder = get_folder_name(username, is_likes=True)
113
+ download_urls(urls, folder)
114
+
115
+ def run_playlist(username: str, set_name: str, is_user_playlist: bool = False):
116
+ logger.info(f"Fetching playlist: {username} / {set_name}")
117
+ urls = extract_playlist(username, set_name, is_user_playlist)
118
+ folder = get_folder_name(username, is_likes=False, set_name=set_name)
119
+ download_urls(urls, folder)
@@ -0,0 +1,458 @@
1
+ import vlc
2
+ import random
3
+ from pathlib import Path
4
+
5
+ from textual.binding import Binding
6
+ from textual.app import App, ComposeResult
7
+ from textual.containers import Container, VerticalScroll
8
+ from textual.widgets import (
9
+ Header,
10
+ Static,
11
+ TabbedContent,
12
+ TabPane,
13
+ ListView,
14
+ ListItem,
15
+ Label,
16
+ )
17
+
18
+ from browse.soundcloud import run_likes, run_playlist
19
+
20
+ MUSIC_DIR = Path.home() / "Music"
21
+
22
+
23
+ def song_playing(song):
24
+ return vlc.MediaPlayer(str(song))
25
+
26
+
27
+ class Lysn(App):
28
+ """Lysn"""
29
+
30
+ CSS = """
31
+ Screen {
32
+ layout: vertical;
33
+ background: #0b0f14;
34
+ color: #d0d7de;
35
+ }
36
+
37
+ #main {
38
+ height: 1fr;
39
+ }
40
+
41
+ TabbedContent {
42
+ height: 1fr;
43
+ }
44
+
45
+ .tab-box {
46
+ border: round #30363d;
47
+ padding: 1 2;
48
+ }
49
+
50
+ .tab-box, .list-view {
51
+ background: #0b0f14;
52
+ color: #d0d7de;
53
+ }
54
+
55
+ .list-view {
56
+ height: 1fr;
57
+ }
58
+
59
+ #player_bar {
60
+ height: 4;
61
+ border-top: solid #30363d;
62
+ padding: 0 1;
63
+ background: #0b0f14;
64
+ color: #c9d1d9;
65
+ }
66
+ """
67
+
68
+ BINDINGS = [
69
+ Binding("q", "quit", "Quit"),
70
+ Binding("enter", "open_item", "Open"),
71
+ Binding("backspace", "go_back", "Back"),
72
+ # Playback controls
73
+ Binding("space", "pausesong", "Pause Song"),
74
+ Binding("s", "stopsong", "Stop Song"),
75
+ Binding("r", "restartsong", "Restart Song"),
76
+ Binding("n", "next_song", "Next Song"),
77
+ Binding("b", "prev_song", "Previous Song"),
78
+ # Seeking
79
+ Binding("d", "forwardsong", "Seek 10 sec"),
80
+ Binding("a", "backwardsong", "Go back 10 sec"),
81
+ # Volume
82
+ Binding("w", "volumeup", "Volume Up"),
83
+ Binding("x", "volumedown", "Volume Down"),
84
+ Binding("m", "volumemute", "Mute"),
85
+ # Album action
86
+ Binding("p", "play_album", "Play Album"),
87
+ Binding("z", "shuffle_album", "Shuffle Album"),
88
+
89
+ Binding("down", "focus_tab_content", "Enter Tab"),
90
+ ]
91
+
92
+ def compose(self) -> ComposeResult:
93
+ yield Header()
94
+
95
+ with Container(id="main"):
96
+ with TabbedContent(id="tabs"):
97
+ with TabPane("Albums", id="albums_tab"):
98
+ self.album_list = ListView(classes="tab-box")
99
+ yield self.album_list
100
+
101
+ with TabPane("Browse", id="browse_tab"):
102
+ self.browser_list = ListView(classes="tab-box")
103
+ yield self.browser_list
104
+
105
+ with TabPane("Help"):
106
+ with VerticalScroll():
107
+ yield Static(
108
+ """
109
+ NAVIGATION
110
+ ----------
111
+ [↑ / ↓] Move selection
112
+ [Enter] Open item / Confirm
113
+ [Backspace] Go back
114
+
115
+ PLAYBACK CONTROLS
116
+ -----------------
117
+ [Space] Pause / Resume
118
+ [S] Stop
119
+ [R] Restart song
120
+ [N] Next song
121
+ [B] Previous song
122
+
123
+ SEEKING
124
+ -------
125
+ [D] Forward 10 seconds
126
+ [A] Backward 10 seconds
127
+
128
+ VOLUME
129
+ ------
130
+ [W] Volume up
131
+ [X] Volume down
132
+ [M] Mute toggle
133
+
134
+ ALBUM ACTIONS
135
+ -------------
136
+ [P] Play album
137
+ [Z] Shuffle album
138
+
139
+ QUIT
140
+ ----
141
+ [Q] Exit application
142
+
143
+ FOR MORE CHECK OUT THE FULL DOCUMENTATION ON:
144
+ https://github.com/wattox00/lysn
145
+ """,
146
+ classes="tab-box",
147
+ markup=False,
148
+ )
149
+
150
+ self.player_text = Static("No song playing", id="player_bar")
151
+ yield self.player_text
152
+
153
+ def on_mount(self) -> None:
154
+ self.current_path = MUSIC_DIR
155
+ self.history = []
156
+ self.browser_mode = "root"
157
+ self.refresh_list()
158
+ self.refresh_browser()
159
+ self.set_interval(1, self.check_song_end)
160
+ self.set_interval(1, self.update_player_bar)
161
+ self.muted = False
162
+ self.sc_user = "your_username"
163
+ self.sc_set = "your_playlist"
164
+ self.input_mode = None
165
+ self.input_buffer = ""
166
+ self.pending_action = None
167
+
168
+ def get_active_tab(self):
169
+ tabs = self.query_one("#tabs", TabbedContent)
170
+ return tabs.active
171
+
172
+ #Player
173
+ def check_song_end(self):
174
+ if hasattr(self, "player") and hasattr(self, "playlist"):
175
+ if self.player.get_state() == vlc.State.Ended:
176
+ self.action_next_song()
177
+
178
+ def play_song_list(self, songs):
179
+ if not songs:
180
+ return
181
+
182
+ self.playlist = songs
183
+ self.current_index = 0
184
+ self.play_current_song()
185
+
186
+ def play_current_song(self):
187
+ if not hasattr(self, "playlist") or not self.playlist:
188
+ return
189
+
190
+ song = self.playlist[self.current_index]
191
+
192
+ if hasattr(self, "player"):
193
+ self.player.stop()
194
+
195
+ self.player = song_playing(song)
196
+ self.volume = getattr(self, "volume", 50)
197
+ self.player.audio_set_volume(self.volume)
198
+ self.player.play()
199
+ self.player_text.update(f"Playing: {song.name}")
200
+
201
+ # Album Nav
202
+ def refresh_list(self):
203
+ self.album_list.clear()
204
+ items = sorted(self.current_path.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
205
+
206
+ for item in items:
207
+ label = f"[DIR] {item.name}" if item.is_dir() else item.name
208
+ self.album_list.append(ListItem(Label(label)))
209
+
210
+ def open_album_item(self):
211
+ if self.album_list.index is None:
212
+ return
213
+
214
+ items = sorted(self.current_path.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
215
+ selected = items[self.album_list.index]
216
+
217
+ if selected.is_dir():
218
+ self.history.append(self.current_path)
219
+ self.current_path = selected
220
+ self.refresh_list()
221
+
222
+ def action_go_back(self) -> None:
223
+ if self.get_active_tab() == "albums_tab":
224
+ if self.history:
225
+ self.current_path = self.history.pop()
226
+ self.refresh_list()
227
+
228
+ elif self.get_active_tab() == "browse_tab":
229
+ if self.browser_mode == "soundcloud_menu":
230
+ self.browser_mode = "root"
231
+ self.refresh_browser()
232
+
233
+ def get_album_songs(self):
234
+ return [f for f in self.current_path.iterdir() if f.is_file()]
235
+
236
+ def action_play_album(self) -> None:
237
+ if self.current_path == MUSIC_DIR:
238
+ return
239
+
240
+ songs = sorted(self.get_album_songs())
241
+ self.play_song_list(songs)
242
+
243
+ def action_shuffle_album(self) -> None:
244
+ if self.current_path == MUSIC_DIR:
245
+ return
246
+
247
+ songs = self.get_album_songs()
248
+ random.shuffle(songs)
249
+ self.play_song_list(songs)
250
+
251
+ def action_focus_tab_content(self):
252
+ if self.get_active_tab() == "albums_tab":
253
+ self.album_list.focus()
254
+ elif self.get_active_tab() == "browse_tab":
255
+ self.browser_list.focus()
256
+
257
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
258
+ if event.list_view is self.album_list:
259
+ self.open_album_item()
260
+ elif event.list_view is self.browser_list:
261
+ self.open_browser_item()
262
+
263
+ # Browse
264
+ def refresh_browser(self):
265
+ self.browser_list.clear()
266
+
267
+ if self.browser_mode == "root":
268
+ options = ["SoundCloud", "Spotify"]
269
+ elif self.browser_mode == "soundcloud_menu":
270
+ options = ["Likes", "Albums", "Playlists"]
271
+ else:
272
+ options = []
273
+
274
+ for opt in options:
275
+ item = ListItem(Label(opt))
276
+ item.data = opt
277
+ self.browser_list.append(item)
278
+
279
+ def open_browser_item(self):
280
+ if self.browser_list.index is None:
281
+ return
282
+
283
+ item = self.browser_list.children[self.browser_list.index]
284
+ label = item.data
285
+
286
+ if self.browser_mode == "root":
287
+ if label == "SoundCloud":
288
+ self.browser_mode = "soundcloud_menu"
289
+ self.refresh_browser()
290
+
291
+ elif self.browser_mode == "soundcloud_menu":
292
+ if label == "Likes":
293
+ self.input_mode = "username"
294
+ self.pending_action = "likes"
295
+ self.input_buffer = ""
296
+ self.player_text.update("Enter username:")
297
+
298
+ elif label == "Albums":
299
+ self.input_mode = "username"
300
+ self.pending_action = "album"
301
+ self.input_buffer = ""
302
+ self.player_text.update("Enter username:")
303
+
304
+ elif label == "Playlists":
305
+ self.input_mode = "username"
306
+ self.pending_action = "playlist"
307
+ self.input_buffer = ""
308
+ self.player_text.update("Enter username:")
309
+
310
+ def action_open_item(self) -> None:
311
+ if self.get_active_tab() == "albums_tab":
312
+ self.open_album_item()
313
+ elif self.get_active_tab() == "browse_tab":
314
+ self.open_browser_item()
315
+
316
+ # Player bar
317
+ def update_player_bar(self):
318
+ if not hasattr(self, "player"):
319
+ return
320
+
321
+ state = self.player.get_state()
322
+
323
+ icon = {
324
+ vlc.State.Playing: "›",
325
+ vlc.State.Paused: "‖",
326
+ }.get(state, "·")
327
+
328
+ current = max(0, self.player.get_time() // 1000)
329
+ total = max(1, self.player.get_length() // 1000)
330
+
331
+ progress = current / total
332
+ bar_len = 30
333
+ filled = int(progress * bar_len)
334
+
335
+ bar = "─" * filled + " " * (bar_len - filled)
336
+
337
+ def fmt(t):
338
+ return f"{t//60:02}:{t%60:02}"
339
+
340
+ time_str = f"{fmt(current)} / {fmt(total)}"
341
+
342
+ vol = getattr(self, "volume", 0)
343
+ muted = getattr(self, "muted", False)
344
+ vol_str = "MUTE" if muted else f"VOL {vol:02}"
345
+
346
+ song = "No song"
347
+ if hasattr(self, "playlist"):
348
+ song = self.playlist[self.current_index].name
349
+
350
+ line1 = f"{icon} {song[:40]}"
351
+
352
+ line2 = f"[{bar}] {time_str} {vol_str}"
353
+
354
+ self.player_text.update(f"{line1}\n{line2}")
355
+
356
+ def action_playsong(self) -> None:
357
+ self.play_current_song()
358
+
359
+ def action_stopsong(self) -> None:
360
+ if hasattr(self, "player"):
361
+ self.player.stop()
362
+
363
+ def action_pausesong(self) -> None:
364
+ if hasattr(self, "player"):
365
+ self.player.pause()
366
+
367
+ def action_restartsong(self) -> None:
368
+ if hasattr(self, "player"):
369
+ self.player.set_time(0)
370
+
371
+ def action_forwardsong(self) -> None:
372
+ if hasattr(self, "player"):
373
+ self.player.set_time(self.player.get_time() + 10000)
374
+
375
+ def action_backwardsong(self) -> None:
376
+ if hasattr(self, "player"):
377
+ self.player.set_time(self.player.get_time() - 10000)
378
+
379
+ def action_volumeup(self) -> None:
380
+ if hasattr(self, "player"):
381
+ self.volume = min(self.volume + 5, 100)
382
+ self.player.audio_set_volume(self.volume)
383
+
384
+ def action_volumedown(self) -> None:
385
+ if hasattr(self, "player"):
386
+ self.volume = max(self.volume - 5, 0)
387
+ self.player.audio_set_volume(self.volume)
388
+
389
+ def action_volumemute(self) -> None:
390
+ if hasattr(self, "player"):
391
+ self.muted = not self.muted
392
+ self.player.audio_set_mute(self.muted)
393
+
394
+ def action_next_song(self) -> None:
395
+ if not hasattr(self, "playlist") or not self.playlist:
396
+ return
397
+
398
+ self.current_index += 1
399
+ if self.current_index >= len(self.playlist):
400
+ self.current_index = 0
401
+
402
+ self.play_current_song()
403
+
404
+ def action_prev_song(self) -> None:
405
+ if not hasattr(self, "playlist") or not self.playlist:
406
+ return
407
+
408
+ self.current_index -= 1
409
+ if self.current_index < 0:
410
+ self.current_index = len(self.playlist) - 1
411
+
412
+ self.play_current_song()
413
+
414
+ # prompt typing
415
+ def on_key(self, event):
416
+ if not self.input_mode:
417
+ return
418
+
419
+ if event.key == "enter":
420
+ if self.input_mode == "username":
421
+ self.temp_username = self.input_buffer
422
+ self.input_buffer = ""
423
+
424
+ if self.pending_action == "likes":
425
+ self.player_text.update("Downloading likes...")
426
+ run_likes(self.temp_username)
427
+ self.player_text.update(f"Done: {self.temp_username}")
428
+ self.input_mode = None
429
+
430
+ else:
431
+ self.input_mode = "setname"
432
+ self.player_text.update("Enter name:")
433
+
434
+ elif self.input_mode == "setname":
435
+ setname = self.input_buffer
436
+ self.input_buffer = ""
437
+
438
+ self.player_text.update("Downloading...")
439
+ run_playlist(
440
+ self.temp_username,
441
+ setname,
442
+ self.pending_action == "playlist"
443
+ )
444
+ self.player_text.update(f"Done: {setname}")
445
+
446
+ self.input_mode = None
447
+
448
+ elif event.key == "backspace":
449
+ self.input_buffer = self.input_buffer[:-1]
450
+
451
+ elif event.character:
452
+ self.input_buffer += event.character
453
+
454
+ self.player_text.update(f"> {self.input_buffer}")
455
+
456
+ if __name__ == "__main__":
457
+ app = Lysn()
458
+ app.run()
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: lysn
3
+ Version: 0.1.4
4
+ Summary: TUI music player
5
+ Author: Wattox00
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: textual>=0.50.0
11
+ Requires-Dist: python-vlc
12
+ Requires-Dist: yt-dlp
13
+ Dynamic: license-file
14
+
15
+ # Lysn - cli_music_player
16
+ A Cli music player mainly for Linux
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ lysn/main.py
5
+ lysn.egg-info/PKG-INFO
6
+ lysn.egg-info/SOURCES.txt
7
+ lysn.egg-info/dependency_links.txt
8
+ lysn.egg-info/entry_points.txt
9
+ lysn.egg-info/requires.txt
10
+ lysn.egg-info/top_level.txt
11
+ lysn/browse/__init__.py
12
+ lysn/browse/soundcloud.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lysn = lysn.main:main
@@ -0,0 +1,3 @@
1
+ textual>=0.50.0
2
+ python-vlc
3
+ yt-dlp
@@ -0,0 +1,2 @@
1
+ dist
2
+ lysn
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lysn"
7
+ version = "0.1.4"
8
+ description = "TUI music player"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+
13
+ authors = [
14
+ { name = "Wattox00" }
15
+ ]
16
+
17
+ dependencies = [
18
+ "textual>=0.50.0",
19
+ "python-vlc",
20
+ "yt-dlp",
21
+ ]
22
+
23
+ [project.scripts]
24
+ lysn = "lysn.main:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["."]
lysn-0.1.4/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+