srgplayer 0.1.0__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.
- srgplayer-0.1.0/.gitignore +10 -0
- srgplayer-0.1.0/.python-version +1 -0
- srgplayer-0.1.0/PKG-INFO +10 -0
- srgplayer-0.1.0/README.md +0 -0
- srgplayer-0.1.0/pyproject.toml +22 -0
- srgplayer-0.1.0/src/srgplayer/__init__.py +3 -0
- srgplayer-0.1.0/src/srgplayer/player.py +287 -0
- srgplayer-0.1.0/uv.lock +67 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
srgplayer-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: srgplayer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal music player that streams from YouTube
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: python-mpv
|
|
9
|
+
Requires-Dist: readchar
|
|
10
|
+
Requires-Dist: yt-dlp
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "srgplayer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Terminal music player that streams from YouTube"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Your Name", email = "you@example.com" }
|
|
10
|
+
]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"python-mpv",
|
|
13
|
+
"yt-dlp",
|
|
14
|
+
"readchar",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
srgplayer = "srgplayer:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Terminal Music Player
|
|
4
|
+
----------------------
|
|
5
|
+
Search and stream songs from YouTube directly in your terminal,
|
|
6
|
+
with simple single-key shortcuts to control playback.
|
|
7
|
+
|
|
8
|
+
Requirements:
|
|
9
|
+
pip install python-mpv yt-dlp readchar --break-system-packages
|
|
10
|
+
|
|
11
|
+
Also requires the 'mpv' media player installed on your system
|
|
12
|
+
(python-mpv is just a binding to it):
|
|
13
|
+
Ubuntu/Debian: sudo apt install mpv
|
|
14
|
+
macOS: brew install mpv
|
|
15
|
+
Windows: https://mpv.io/installation/ (add to PATH)
|
|
16
|
+
|
|
17
|
+
Controls (press key, no Enter needed):
|
|
18
|
+
space pause / resume
|
|
19
|
+
n next song in queue
|
|
20
|
+
p previous song in queue
|
|
21
|
+
a search + add a song / playlist
|
|
22
|
+
l list current queue
|
|
23
|
+
+/- volume up / down
|
|
24
|
+
q quit
|
|
25
|
+
|
|
26
|
+
This version uses mpv's own native playlist engine to handle
|
|
27
|
+
auto-advance to the next track. That's what mpv is built for, so
|
|
28
|
+
it's far more reliable than trying to detect "song ended" from
|
|
29
|
+
Python and manually calling play() again.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import mpv
|
|
36
|
+
import yt_dlp
|
|
37
|
+
import readchar
|
|
38
|
+
except ImportError as e:
|
|
39
|
+
print(f"Missing dependency: {e}")
|
|
40
|
+
print("Install with: pip install python-mpv yt-dlp readchar --break-system-packages")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MusicPlayer:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.player = mpv.MPV(
|
|
47
|
+
ytdl=True,
|
|
48
|
+
video=False,
|
|
49
|
+
input_default_bindings=False,
|
|
50
|
+
terminal=False,
|
|
51
|
+
osc=False,
|
|
52
|
+
keep_open="no", # auto-advance to next playlist item at end of track
|
|
53
|
+
)
|
|
54
|
+
self.queue = [] # mirrors mpv's playlist: [{"title": str, "url": str}, ...]
|
|
55
|
+
self.current_index = -1
|
|
56
|
+
|
|
57
|
+
@self.player.property_observer('playlist-pos')
|
|
58
|
+
def _on_pos_change(_name, value):
|
|
59
|
+
if value is not None and 0 <= value < len(self.queue):
|
|
60
|
+
self.current_index = value
|
|
61
|
+
print(f"\n▶ Now playing: {self.queue[value]['title']}")
|
|
62
|
+
|
|
63
|
+
def _entry_to_url(self, e):
|
|
64
|
+
raw_url = e.get("url")
|
|
65
|
+
if raw_url and raw_url.startswith("http"):
|
|
66
|
+
return raw_url
|
|
67
|
+
vid_id = e.get("id")
|
|
68
|
+
if vid_id:
|
|
69
|
+
return f"https://www.youtube.com/watch?v={vid_id}"
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def search(self, query, limit=5):
|
|
73
|
+
ydl_opts = {
|
|
74
|
+
"quiet": True,
|
|
75
|
+
"no_warnings": True,
|
|
76
|
+
"extract_flat": "in_playlist",
|
|
77
|
+
"default_search": f"ytsearch{limit}",
|
|
78
|
+
"skip_download": True,
|
|
79
|
+
}
|
|
80
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
81
|
+
info = ydl.extract_info(query, download=False)
|
|
82
|
+
entries = info.get("entries", [])
|
|
83
|
+
results = []
|
|
84
|
+
for e in entries:
|
|
85
|
+
if not e:
|
|
86
|
+
continue
|
|
87
|
+
url = self._entry_to_url(e)
|
|
88
|
+
if not url:
|
|
89
|
+
continue
|
|
90
|
+
results.append({"title": e.get("title", "Unknown title"), "url": url})
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
def is_playlist_url(self, text):
|
|
94
|
+
return text.strip().startswith(("http://", "https://")) and "list=" in text
|
|
95
|
+
|
|
96
|
+
def search_playlists(self, query, limit=5):
|
|
97
|
+
from urllib.parse import quote
|
|
98
|
+
# sp=EgIQAw%3D%3D filters YouTube search results to playlists only
|
|
99
|
+
search_url = f"https://www.youtube.com/results?search_query={quote(query)}&sp=EgIQAw%3D%3D"
|
|
100
|
+
ydl_opts = {
|
|
101
|
+
"quiet": True,
|
|
102
|
+
"no_warnings": True,
|
|
103
|
+
"extract_flat": "in_playlist",
|
|
104
|
+
"skip_download": True,
|
|
105
|
+
"playlistend": limit,
|
|
106
|
+
}
|
|
107
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
108
|
+
info = ydl.extract_info(search_url, download=False)
|
|
109
|
+
entries = info.get("entries", [])[:limit]
|
|
110
|
+
results = []
|
|
111
|
+
for e in entries:
|
|
112
|
+
if not e:
|
|
113
|
+
continue
|
|
114
|
+
pl_id = e.get("id") or e.get("url")
|
|
115
|
+
url = e.get("url") or f"https://www.youtube.com/playlist?list={pl_id}"
|
|
116
|
+
if not url.startswith("http"):
|
|
117
|
+
url = f"https://www.youtube.com/playlist?list={pl_id}"
|
|
118
|
+
results.append({"title": e.get("title", "Unknown playlist"), "url": url})
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
def add_to_playlist(self, title, url, play_now=False):
|
|
122
|
+
"""Add one song both to our display queue and mpv's real playlist."""
|
|
123
|
+
if play_now:
|
|
124
|
+
self.clear_queue()
|
|
125
|
+
self.queue.append({"title": title, "url": url})
|
|
126
|
+
mode = "append-play" if len(self.queue) == 1 else "append"
|
|
127
|
+
self.player.command("loadfile", url, mode)
|
|
128
|
+
|
|
129
|
+
def clear_queue(self):
|
|
130
|
+
self.player.command("playlist-clear")
|
|
131
|
+
self.player.command("stop")
|
|
132
|
+
self.queue = []
|
|
133
|
+
self.current_index = -1
|
|
134
|
+
|
|
135
|
+
def ask_play_now(self):
|
|
136
|
+
if not self.queue:
|
|
137
|
+
return True # nothing playing yet, no need to ask
|
|
138
|
+
choice = input("[n]ow (clears current queue) or add to [e]nd? (e): ").strip().lower()
|
|
139
|
+
return choice == "n"
|
|
140
|
+
|
|
141
|
+
def import_playlist(self, url):
|
|
142
|
+
play_now = self.ask_play_now()
|
|
143
|
+
ydl_opts = {
|
|
144
|
+
"quiet": True,
|
|
145
|
+
"no_warnings": True,
|
|
146
|
+
"extract_flat": "in_playlist",
|
|
147
|
+
"skip_download": True,
|
|
148
|
+
}
|
|
149
|
+
print("Importing playlist (this can take a moment for long playlists)...")
|
|
150
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
151
|
+
info = ydl.extract_info(url, download=False)
|
|
152
|
+
entries = info.get("entries", [])
|
|
153
|
+
added = 0
|
|
154
|
+
for e in entries:
|
|
155
|
+
if not e:
|
|
156
|
+
continue
|
|
157
|
+
vid_url = self._entry_to_url(e)
|
|
158
|
+
if not vid_url:
|
|
159
|
+
continue
|
|
160
|
+
# only clear the queue once, on the very first imported track
|
|
161
|
+
self.add_to_playlist(e.get("title", "Unknown title"), vid_url, play_now=(play_now and added == 0))
|
|
162
|
+
added += 1
|
|
163
|
+
print(f"Imported {added} songs from playlist.")
|
|
164
|
+
|
|
165
|
+
def add_song_interactive(self):
|
|
166
|
+
query = input(
|
|
167
|
+
"\nSearch a song, type 'playlist: <query>' to search playlists, "
|
|
168
|
+
"or paste a playlist URL: "
|
|
169
|
+
).strip()
|
|
170
|
+
if not query:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
if self.is_playlist_url(query):
|
|
174
|
+
self.import_playlist(query)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if query.lower().startswith("playlist:"):
|
|
178
|
+
pl_query = query.split(":", 1)[1].strip()
|
|
179
|
+
if not pl_query:
|
|
180
|
+
return
|
|
181
|
+
print("Searching playlists...")
|
|
182
|
+
results = self.search_playlists(pl_query)
|
|
183
|
+
if not results:
|
|
184
|
+
print("No playlists found.")
|
|
185
|
+
return
|
|
186
|
+
for i, r in enumerate(results):
|
|
187
|
+
print(f" [{i}] {r['title']}")
|
|
188
|
+
choice = input("Pick a playlist to import (Enter to cancel): ").strip()
|
|
189
|
+
if not choice.isdigit():
|
|
190
|
+
return
|
|
191
|
+
idx = int(choice)
|
|
192
|
+
if 0 <= idx < len(results):
|
|
193
|
+
self.import_playlist(results[idx]["url"])
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
print("Searching...")
|
|
197
|
+
results = self.search(query)
|
|
198
|
+
if not results:
|
|
199
|
+
print("No results found.")
|
|
200
|
+
return
|
|
201
|
+
for i, r in enumerate(results):
|
|
202
|
+
print(f" [{i}] {r['title']}")
|
|
203
|
+
choice = input("Pick number to add (Enter to cancel): ").strip()
|
|
204
|
+
if not choice.isdigit():
|
|
205
|
+
return
|
|
206
|
+
idx = int(choice)
|
|
207
|
+
if 0 <= idx < len(results):
|
|
208
|
+
play_now = self.ask_play_now()
|
|
209
|
+
self.add_to_playlist(results[idx]["title"], results[idx]["url"], play_now=play_now)
|
|
210
|
+
print(f"Added: {results[idx]['title']}")
|
|
211
|
+
|
|
212
|
+
def next_song(self):
|
|
213
|
+
try:
|
|
214
|
+
self.player.command("playlist-next", "weak")
|
|
215
|
+
except Exception:
|
|
216
|
+
print("\nAlready at the end of the queue.")
|
|
217
|
+
|
|
218
|
+
def prev_song(self):
|
|
219
|
+
try:
|
|
220
|
+
self.player.command("playlist-prev", "weak")
|
|
221
|
+
except Exception:
|
|
222
|
+
print("\nAlready at the start of the queue.")
|
|
223
|
+
|
|
224
|
+
def toggle_pause(self):
|
|
225
|
+
self.player.pause = not self.player.pause
|
|
226
|
+
state = "⏸ Paused" if self.player.pause else "▶ Playing"
|
|
227
|
+
print(f"\r{state} ", end="", flush=True)
|
|
228
|
+
|
|
229
|
+
def list_queue(self):
|
|
230
|
+
print("\n--- Queue ---")
|
|
231
|
+
if not self.queue:
|
|
232
|
+
print("(empty)")
|
|
233
|
+
for i, s in enumerate(self.queue):
|
|
234
|
+
marker = "→" if i == self.current_index else " "
|
|
235
|
+
print(f" {marker} [{i}] {s['title']}")
|
|
236
|
+
print("-------------")
|
|
237
|
+
|
|
238
|
+
def volume(self, delta):
|
|
239
|
+
try:
|
|
240
|
+
self.player.volume = max(0, min(100, self.player.volume + delta))
|
|
241
|
+
print(f"\rVolume: {int(self.player.volume)}% ", end="", flush=True)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def run(self):
|
|
246
|
+
print("=== SRG Terminal Music Player ===")
|
|
247
|
+
print("Controls: [space]=pause/play [n]=next [p]=prev [a]=add song/playlist")
|
|
248
|
+
print(" [l]=list queue [c]=clear queue & stop [+/-]=volume [q]=quit")
|
|
249
|
+
print("Tip: at the prompt, type a song name to search songs,")
|
|
250
|
+
print(" 'playlist: <query>' to search playlists,")
|
|
251
|
+
print(" or paste a playlist URL directly.")
|
|
252
|
+
print(" When adding, you'll be asked to play it now (clears the")
|
|
253
|
+
print(" current queue) or add it to the end.\n")
|
|
254
|
+
|
|
255
|
+
self.add_song_interactive()
|
|
256
|
+
|
|
257
|
+
while True:
|
|
258
|
+
key = readchar.readkey()
|
|
259
|
+
if key == " ":
|
|
260
|
+
self.toggle_pause()
|
|
261
|
+
elif key.lower() == "n":
|
|
262
|
+
self.next_song()
|
|
263
|
+
elif key.lower() == "p":
|
|
264
|
+
self.prev_song()
|
|
265
|
+
elif key.lower() == "a":
|
|
266
|
+
self.add_song_interactive()
|
|
267
|
+
elif key.lower() == "l":
|
|
268
|
+
self.list_queue()
|
|
269
|
+
elif key.lower() == "c":
|
|
270
|
+
self.clear_queue()
|
|
271
|
+
print("\nQueue cleared and playback stopped.")
|
|
272
|
+
elif key == "+":
|
|
273
|
+
self.volume(5)
|
|
274
|
+
elif key == "-":
|
|
275
|
+
self.volume(-5)
|
|
276
|
+
elif key.lower() == "q":
|
|
277
|
+
print("\nBye!")
|
|
278
|
+
self.player.terminate()
|
|
279
|
+
sys.exit(0)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main():
|
|
283
|
+
MusicPlayer().run()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
main()
|
srgplayer-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.9"
|
|
4
|
+
resolution-markers = [
|
|
5
|
+
"python_full_version >= '3.10'",
|
|
6
|
+
"python_full_version < '3.10'",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
[[package]]
|
|
10
|
+
name = "python-mpv"
|
|
11
|
+
version = "1.0.8"
|
|
12
|
+
source = { registry = "https://pypi.org/simple" }
|
|
13
|
+
sdist = { url = "https://files.pythonhosted.org/packages/77/bc/6aa34c8805ff62e2fefc7d171563c60598aac215d5241186031a2c839935/python_mpv-1.0.8.tar.gz", hash = "sha256:017fa359da059c831a94c419083491903e6d2f7c81b9841c33c196cabf4b3fe3", size = 52680, upload-time = "2025-04-25T09:51:40.048Z" }
|
|
14
|
+
wheels = [
|
|
15
|
+
{ url = "https://files.pythonhosted.org/packages/22/f3/4c632eaacebfc62ab9414586137aecf6c0ea2a1e99708cf5a4c0dec13ae0/python_mpv-1.0.8-py3-none-any.whl", hash = "sha256:b5296403e990fb7348df4ca2211937a030f524bf638657bda7e45a00bc2df0cd", size = 46169, upload-time = "2025-04-25T09:51:37.775Z" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[package]]
|
|
19
|
+
name = "readchar"
|
|
20
|
+
version = "4.2.2"
|
|
21
|
+
source = { registry = "https://pypi.org/simple" }
|
|
22
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ed/49/a10341024c45bed95d13197ec9ef0f4e2fd10b5ca6e7f8d7684d18082398/readchar-4.2.2.tar.gz", hash = "sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8", size = 9762, upload-time = "2026-04-06T19:45:54.226Z" }
|
|
23
|
+
wheels = [
|
|
24
|
+
{ url = "https://files.pythonhosted.org/packages/3d/ca/36133653e00939922dd1416f4c56177361289172a30563fcb9552c9ccde4/readchar-4.2.2-py3-none-any.whl", hash = "sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200", size = 9401, upload-time = "2026-04-06T19:45:52.993Z" },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[[package]]
|
|
28
|
+
name = "srgplayer"
|
|
29
|
+
version = "0.1.0"
|
|
30
|
+
source = { editable = "." }
|
|
31
|
+
dependencies = [
|
|
32
|
+
{ name = "python-mpv" },
|
|
33
|
+
{ name = "readchar" },
|
|
34
|
+
{ name = "yt-dlp", version = "2025.10.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
|
35
|
+
{ name = "yt-dlp", version = "2026.7.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[package.metadata]
|
|
39
|
+
requires-dist = [
|
|
40
|
+
{ name = "python-mpv" },
|
|
41
|
+
{ name = "readchar" },
|
|
42
|
+
{ name = "yt-dlp" },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[[package]]
|
|
46
|
+
name = "yt-dlp"
|
|
47
|
+
version = "2025.10.14"
|
|
48
|
+
source = { registry = "https://pypi.org/simple" }
|
|
49
|
+
resolution-markers = [
|
|
50
|
+
"python_full_version < '3.10'",
|
|
51
|
+
]
|
|
52
|
+
sdist = { url = "https://files.pythonhosted.org/packages/03/b7/dab729345e22891e79294273bc59c5213a1ec87331f49cb82ccea2b1bc9f/yt_dlp-2025.10.14.tar.gz", hash = "sha256:b18436aa9bb6f04354fd78d31ad9eeaae8c81b6a859f07072b25c18cd6c25844", size = 3045272, upload-time = "2025-10-14T23:39:52.688Z" }
|
|
53
|
+
wheels = [
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/b0/19/399c85d29bd7b366b31ede82698f7963374e5a3842ae9de0cde6514506b0/yt_dlp-2025.10.14-py3-none-any.whl", hash = "sha256:0b9da17eda1bbf48e2315130043d7993fd4ca1c5a35571f8231da1a910c9c115", size = 3248664, upload-time = "2025-10-14T23:39:49.95Z" },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "yt-dlp"
|
|
59
|
+
version = "2026.7.4"
|
|
60
|
+
source = { registry = "https://pypi.org/simple" }
|
|
61
|
+
resolution-markers = [
|
|
62
|
+
"python_full_version >= '3.10'",
|
|
63
|
+
]
|
|
64
|
+
sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" }
|
|
65
|
+
wheels = [
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" },
|
|
67
|
+
]
|