spci-sonic-pulse 2.0.1__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.
- spci/__init__.py +0 -0
- spci/getmusic.py +54 -0
- spci/mp.py +508 -0
- spci_sonic_pulse-2.0.1.dist-info/METADATA +11 -0
- spci_sonic_pulse-2.0.1.dist-info/RECORD +8 -0
- spci_sonic_pulse-2.0.1.dist-info/WHEEL +5 -0
- spci_sonic_pulse-2.0.1.dist-info/entry_points.txt +2 -0
- spci_sonic_pulse-2.0.1.dist-info/top_level.txt +1 -0
spci/__init__.py
ADDED
|
File without changes
|
spci/getmusic.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import yt_dlp
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def get_music(query):
|
|
5
|
+
"""
|
|
6
|
+
Searches YouTube and filters results to include only song-length videos.
|
|
7
|
+
"""
|
|
8
|
+
# Define your maximum duration in seconds (e.g., 6 minutes)
|
|
9
|
+
MAX_DURATION = 360
|
|
10
|
+
|
|
11
|
+
ydl_opts = {
|
|
12
|
+
'format': 'bestaudio/best',
|
|
13
|
+
'quiet': True,
|
|
14
|
+
'no_warnings': True,
|
|
15
|
+
'extract_flat': True,
|
|
16
|
+
'nocheckcertificate': True,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# We add "audio" and "song" to the query for better results
|
|
20
|
+
search_query = f"ytsearch15:{query} song audio"
|
|
21
|
+
|
|
22
|
+
songs = []
|
|
23
|
+
try:
|
|
24
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
25
|
+
result = ydl.extract_info(search_query, download=False)
|
|
26
|
+
|
|
27
|
+
if 'entries' in result:
|
|
28
|
+
for entry in result['entries']:
|
|
29
|
+
duration_sec = entry.get('duration')
|
|
30
|
+
|
|
31
|
+
# FILTER: Skip videos that are too long or have no duration info
|
|
32
|
+
if not duration_sec or duration_sec > MAX_DURATION:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
duration_str = f"{int(duration_sec // 60)}:{int(duration_sec % 60):02d}"
|
|
36
|
+
|
|
37
|
+
songs.append({
|
|
38
|
+
'title': entry.get('title'),
|
|
39
|
+
'videoId': entry.get('id'),
|
|
40
|
+
'artists': entry.get('uploader') or "Unknown",
|
|
41
|
+
'album': "YouTube",
|
|
42
|
+
'duration': duration_str
|
|
43
|
+
})
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"\n[bold red][!] Search Error:[/bold red] {e}")
|
|
46
|
+
|
|
47
|
+
return songs
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
query = input("Search YouTube: ")
|
|
51
|
+
results = get_music(query)
|
|
52
|
+
if results:
|
|
53
|
+
for i, song in enumerate(results, start=1):
|
|
54
|
+
print(f"{i}. {song['title']} by {song['artists']} - {song['duration']}")
|
spci/mp.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
import sys
|
|
6
|
+
import shutil
|
|
7
|
+
import typer
|
|
8
|
+
import requests
|
|
9
|
+
import yt_dlp
|
|
10
|
+
import zipfile
|
|
11
|
+
import io
|
|
12
|
+
import random
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
|
|
16
|
+
from rich.layout import Layout
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.align import Align
|
|
20
|
+
from rich import box
|
|
21
|
+
import math
|
|
22
|
+
import random
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
|
|
25
|
+
# External project modules
|
|
26
|
+
from .getmusic import get_music
|
|
27
|
+
from tinydb import TinyDB, Query
|
|
28
|
+
|
|
29
|
+
__version__ = "2.0.0"
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
app = typer.Typer()
|
|
33
|
+
|
|
34
|
+
HISTORY_FILE = "play_history.txt"
|
|
35
|
+
|
|
36
|
+
# --- CONFIGURATION & PATHS ---
|
|
37
|
+
# Storing everything in a hidden folder in the User's home directory
|
|
38
|
+
APP_DIR = os.path.join(os.path.expanduser("~"), ".spci")
|
|
39
|
+
BIN_DIR = os.path.join(APP_DIR, "bin")
|
|
40
|
+
FAV_DIR = os.path.join(APP_DIR, "fav_audio") # Store actual .mp3 files here
|
|
41
|
+
FAV_DB_PATH = os.path.join(APP_DIR, "favorites.json") # NoSQL Metadata
|
|
42
|
+
|
|
43
|
+
# Windows Binary Paths
|
|
44
|
+
FFPLAY_PATH = os.path.join(BIN_DIR, "ffplay.exe")
|
|
45
|
+
FFMPEG_PATH = os.path.join(BIN_DIR, "ffmpeg.exe")
|
|
46
|
+
FFPROBE_PATH = os.path.join(BIN_DIR, "ffprobe.exe")
|
|
47
|
+
|
|
48
|
+
# Initialize directories
|
|
49
|
+
os.makedirs(BIN_DIR, exist_ok=True)
|
|
50
|
+
os.makedirs(FAV_DIR, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Initialize NoSQL Database
|
|
53
|
+
db = TinyDB(FAV_DB_PATH)
|
|
54
|
+
fav_table = db.table('favorites')
|
|
55
|
+
|
|
56
|
+
# --- UI COMPONENTS ---
|
|
57
|
+
|
|
58
|
+
def make_layout() -> Layout:
|
|
59
|
+
"""Creates a structured grid for the CLI interface."""
|
|
60
|
+
layout = Layout(name="root")
|
|
61
|
+
layout.split(
|
|
62
|
+
Layout(name="header", size=3),
|
|
63
|
+
Layout(name="main", ratio=1),
|
|
64
|
+
Layout(name="footer", size=7)
|
|
65
|
+
)
|
|
66
|
+
layout["main"].split_row(
|
|
67
|
+
Layout(name="left", ratio=2),
|
|
68
|
+
Layout(name="right", ratio=1),
|
|
69
|
+
)
|
|
70
|
+
return layout
|
|
71
|
+
|
|
72
|
+
def get_header():
|
|
73
|
+
return Panel(
|
|
74
|
+
Align.center(f"[bold cyan]SPCI SONIC PULSE[/bold cyan] v{__version__} | [bold yellow]Play once, play again & again[/bold yellow]"),
|
|
75
|
+
box=box.ROUNDED,
|
|
76
|
+
style="white on black"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_now_playing_panel(title, artist, is_offline=False, t=0.0):
|
|
81
|
+
"""
|
|
82
|
+
Elegant ribbon visualizer:
|
|
83
|
+
- Adapts to terminal size (console.size)
|
|
84
|
+
- Smooth sine backbone + per-column micro-noise
|
|
85
|
+
- Soft falloff with graded glyphs and color shift
|
|
86
|
+
"""
|
|
87
|
+
# Terminal-aware sizing (keeps left panel compact)
|
|
88
|
+
term = console.size
|
|
89
|
+
viz_width = max(24, min(48, term.width // 3))
|
|
90
|
+
viz_height = max(8, min(16, term.height // 4))
|
|
91
|
+
|
|
92
|
+
cx = viz_width // 2
|
|
93
|
+
cy = viz_height // 2
|
|
94
|
+
|
|
95
|
+
# Glyph ramp from faint -> bold
|
|
96
|
+
glyphs = [" ", "·", "•", "●", "█"]
|
|
97
|
+
colors = ["dim", "red", "cyan", "indigo", "magenta", "green", "yellow", "orange", "bright_white"]
|
|
98
|
+
|
|
99
|
+
# Parameters that control elegance
|
|
100
|
+
base_freq = 0.9 + (viz_width / 80) # spatial frequency
|
|
101
|
+
speed = 1 # temporal speed
|
|
102
|
+
amplitude = (viz_height / 2.5) # vertical swing
|
|
103
|
+
smoothness = 1.6 # how soft the falloff is
|
|
104
|
+
|
|
105
|
+
# Build grid rows (top->bottom)
|
|
106
|
+
grid_rows = []
|
|
107
|
+
for y in range(viz_height):
|
|
108
|
+
row = []
|
|
109
|
+
for x in range(viz_width):
|
|
110
|
+
# backbone: smooth sine across x, offset by time and small noise
|
|
111
|
+
backbone = math.sin((x / viz_width) * base_freq * 2 * math.pi + t * speed)
|
|
112
|
+
micro = math.sin((x * 0.7 + y * 0.4) * 0.4 + t * 1.7) * 0.15
|
|
113
|
+
y_center = cy + (backbone + micro) * amplitude
|
|
114
|
+
|
|
115
|
+
# distance from ribbon centerline for this column
|
|
116
|
+
dist = abs(y - y_center)
|
|
117
|
+
|
|
118
|
+
# normalized intensity (1 at center, decays to 0)
|
|
119
|
+
intensity = max(0.0, 1.0 - (dist / smoothness))
|
|
120
|
+
idx = int(intensity * (len(glyphs) - 1))
|
|
121
|
+
|
|
122
|
+
# subtle phase-based color shift across x
|
|
123
|
+
color_shift = int(((math.sin(t * 0.6 + x * 0.12) + 1) / 2) * (len(colors) - 1))
|
|
124
|
+
color_idx = min(len(colors) - 1, max(0, idx + color_shift - 1))
|
|
125
|
+
|
|
126
|
+
ch = glyphs[idx]
|
|
127
|
+
color = colors[color_idx]
|
|
128
|
+
# keep markup lean
|
|
129
|
+
row.append(f"[{color}]{ch}[/{color}]")
|
|
130
|
+
grid_rows.append("".join(row))
|
|
131
|
+
|
|
132
|
+
vis = "\n".join(grid_rows)
|
|
133
|
+
source_tag = (
|
|
134
|
+
"[bold red]● OFFLINE (LOCAL)[/bold red]" if is_offline
|
|
135
|
+
else "[bold green]● STREAMING (YOUTUBE)[/bold green]"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
content = f"""
|
|
139
|
+
[bold white]TITLE :[/bold white] [yellow]{title}[/yellow]
|
|
140
|
+
[bold white]ARTIST:[/bold white] [cyan]{artist}[/cyan]
|
|
141
|
+
[bold white]STATUS:[/bold white] {source_tag}
|
|
142
|
+
{vis}
|
|
143
|
+
""".rstrip()
|
|
144
|
+
|
|
145
|
+
return Panel(Align.center(content), title="[bold green]NOW PLAYING[/bold green]", border_style="green")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_controls_panel():
|
|
150
|
+
return Panel(
|
|
151
|
+
Align.center("[bold white]ACTIVE SESSION[/bold white]\n[dim]Press Ctrl+C to stop playback and return to terminal[/dim]"),
|
|
152
|
+
title="Controls",
|
|
153
|
+
border_style="blue"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def get_stats_panel():
|
|
157
|
+
"""Sidebar showing database status and history."""
|
|
158
|
+
try:
|
|
159
|
+
fav_count = len(fav_table.all())
|
|
160
|
+
content = f"[bold green]Offline Songs: {fav_count}[/bold green]\n\n"
|
|
161
|
+
content += "[bold white]Recent Activity:[/bold white]\n"
|
|
162
|
+
if os.path.exists(HISTORY_FILE):
|
|
163
|
+
with open(HISTORY_FILE, "r") as f:
|
|
164
|
+
lines = f.readlines()[-3:]
|
|
165
|
+
content += "".join([f"[dim]» {line.split('|')[0].strip()[:20]}...[/dim]\n" for line in lines])
|
|
166
|
+
else:
|
|
167
|
+
content += "[dim]No recent plays.[/dim]"
|
|
168
|
+
except:
|
|
169
|
+
content = "[red]DB Access Error[/red]"
|
|
170
|
+
|
|
171
|
+
return Panel(content, title="SPCI Stats", border_style="green")
|
|
172
|
+
|
|
173
|
+
# --- CORE BACKEND LOGIC ---
|
|
174
|
+
def auto_install_dependencies(system):
|
|
175
|
+
"""Uses subprocess to install ffmpeg and mpv based on the OS."""
|
|
176
|
+
try:
|
|
177
|
+
if system == "Darwin": # macOS
|
|
178
|
+
if shutil.which("brew"):
|
|
179
|
+
console.print("[yellow]macOS detected. Installing via Homebrew...[/yellow]")
|
|
180
|
+
subprocess.run(["brew", "install", "ffmpeg", "mpv"], check=True)
|
|
181
|
+
else:
|
|
182
|
+
console.print("[red]Homebrew not found. Please install Homebrew first.[/red]")
|
|
183
|
+
|
|
184
|
+
elif system == "Linux":
|
|
185
|
+
# Check for common Linux package managers
|
|
186
|
+
if shutil.which("apt"):
|
|
187
|
+
console.print("[yellow]Linux (Debian/Ubuntu) detected. Installing via apt...[/yellow]")
|
|
188
|
+
# Using sudo may require user password input in terminal
|
|
189
|
+
subprocess.run(["sudo", "apt", "update"], check=True)
|
|
190
|
+
subprocess.run(["sudo", "apt", "install", "-y", "ffmpeg", "mpv"], check=True)
|
|
191
|
+
elif shutil.which("pacman"):
|
|
192
|
+
console.print("[yellow]Arch Linux detected. Installing via pacman...[/yellow]")
|
|
193
|
+
subprocess.run(["sudo", "pacman", "-S", "--noconfirm", "ffmpeg", "mpv"], check=True)
|
|
194
|
+
elif shutil.which("dnf"):
|
|
195
|
+
console.print("[yellow]Fedora detected. Installing via dnf...[/yellow]")
|
|
196
|
+
subprocess.run(["sudo", "dnf", "install", "-y", "ffmpeg", "mpv"], check=True)
|
|
197
|
+
except subprocess.CalledProcessError as e:
|
|
198
|
+
console.print(f"[bold red]Installation failed:[/bold red] {e}")
|
|
199
|
+
|
|
200
|
+
def get_player_command():
|
|
201
|
+
"""Checks for binaries. Uses local Trinity on Windows, system mpv on Linux/Mac."""
|
|
202
|
+
system = platform.system()
|
|
203
|
+
|
|
204
|
+
if system == "Windows":
|
|
205
|
+
ffplay_flags = ["-nodisp", "-autoexit", "-loglevel", "quiet", "-infbuf"]
|
|
206
|
+
if all(os.path.exists(p) for p in [FFPLAY_PATH, FFMPEG_PATH, FFPROBE_PATH]):
|
|
207
|
+
return [FFPLAY_PATH] + ffplay_flags
|
|
208
|
+
return download_trinity_windows(ffplay_flags)
|
|
209
|
+
|
|
210
|
+
# LINUX/MAC: Look for mpv first as it's the most stable
|
|
211
|
+
mpv_path = shutil.which("mpv")
|
|
212
|
+
if mpv_path:
|
|
213
|
+
return [mpv_path, "--no-video", "--gapless-audio=yes"]
|
|
214
|
+
|
|
215
|
+
# Fallback to ffplay only if mpv is missing
|
|
216
|
+
ffplay_path = shutil.which("ffplay")
|
|
217
|
+
if ffplay_path:
|
|
218
|
+
return [ffplay_path, "-nodisp", "-autoexit", "-loglevel", "quiet"]
|
|
219
|
+
|
|
220
|
+
console.print("[bold red]Error: No player found.[/bold red] Please run: sudo apt install mpv")
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def download_trinity_windows(flags):
|
|
225
|
+
"""Fixed: Path-agnostic extraction for Windows binaries."""
|
|
226
|
+
console.print("\n[bold yellow]Requirement Missing: Audio Engine not found.[/bold yellow]")
|
|
227
|
+
url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
|
228
|
+
|
|
229
|
+
response = requests.get(url, stream=True)
|
|
230
|
+
total_size = int(response.headers.get('content-length', 0))
|
|
231
|
+
buffer = io.BytesIO()
|
|
232
|
+
|
|
233
|
+
with Progress(SpinnerColumn(), TextColumn("[green]Fetching Engine..."), BarColumn(), console=console) as progress:
|
|
234
|
+
task = progress.add_task("Downloading", total=total_size)
|
|
235
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
236
|
+
buffer.write(chunk)
|
|
237
|
+
progress.update(task, advance=len(chunk))
|
|
238
|
+
|
|
239
|
+
buffer.seek(0)
|
|
240
|
+
with zipfile.ZipFile(buffer) as z:
|
|
241
|
+
for member in z.namelist():
|
|
242
|
+
filename = os.path.basename(member)
|
|
243
|
+
# Extracts specifically into your local .spci/bin folder
|
|
244
|
+
if filename == "ffplay.exe":
|
|
245
|
+
with open(FFPLAY_PATH, "wb") as f: f.write(z.read(member))
|
|
246
|
+
elif filename == "ffmpeg.exe":
|
|
247
|
+
with open(FFMPEG_PATH, "wb") as f: f.write(z.read(member))
|
|
248
|
+
elif filename == "ffprobe.exe":
|
|
249
|
+
with open(FFPROBE_PATH, "wb") as f: f.write(z.read(member))
|
|
250
|
+
return [FFPLAY_PATH] + flags
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def log_history(name, video_id):
|
|
255
|
+
with open(HISTORY_FILE, "a") as f:
|
|
256
|
+
f.write(f"{name} | {video_id}\n")
|
|
257
|
+
|
|
258
|
+
# --- USER COMMANDS ---
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.command()
|
|
262
|
+
def setup():
|
|
263
|
+
subprocess.run(["pip", "install", "-e", "."], check=True)
|
|
264
|
+
|
|
265
|
+
@app.command(short_help="Add song to storage (Raw format for mpv)")
|
|
266
|
+
def add_fav(video_id: str):
|
|
267
|
+
"""Downloads raw audio without needing ffmpeg conversion on Linux/Mac."""
|
|
268
|
+
system = platform.system()
|
|
269
|
+
url = f"https://www.youtube.com/watch?v={video_id}"
|
|
270
|
+
|
|
271
|
+
# On Windows, we still use our local ffmpeg to make .mp3s
|
|
272
|
+
# On Linux/Mac, we download the raw file to avoid ffmpeg dependencies
|
|
273
|
+
if system == "Windows":
|
|
274
|
+
ydl_opts = {
|
|
275
|
+
'format': 'bestaudio/best',
|
|
276
|
+
'outtmpl': os.path.join(FAV_DIR, video_id),
|
|
277
|
+
'ffmpeg_location': BIN_DIR,
|
|
278
|
+
'postprocessors': [{'key': 'FFmpegExtractAudio','preferredcodec': 'mp3','preferredquality': '64'}],
|
|
279
|
+
}
|
|
280
|
+
else:
|
|
281
|
+
# NO POST-PROCESSING: Just get the raw audio file (usually .webm or .m4a)
|
|
282
|
+
ydl_opts = {
|
|
283
|
+
'format': 'bestaudio/best',
|
|
284
|
+
'outtmpl': os.path.join(FAV_DIR, f"{video_id}.%(ext)s"),
|
|
285
|
+
'quiet': True,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
with console.status(f"[bold green]Downloading '{video_id}'...[/bold green]"):
|
|
289
|
+
try:
|
|
290
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
291
|
+
info = ydl.extract_info(url, download=True)
|
|
292
|
+
# Find exactly what file was saved
|
|
293
|
+
ext = info.get('ext', 'mp3') if system == "Windows" else info['ext']
|
|
294
|
+
final_path = os.path.join(FAV_DIR, f"{video_id}.{ext}")
|
|
295
|
+
|
|
296
|
+
fav_table.upsert({
|
|
297
|
+
'video_id': video_id,
|
|
298
|
+
'title': info.get('title'),
|
|
299
|
+
'artist': info.get('uploader'),
|
|
300
|
+
'path': final_path
|
|
301
|
+
}, Query().video_id == video_id)
|
|
302
|
+
console.print(f"[bold green]Success![/bold green] Saved as {info['ext']} for mpv playback.")
|
|
303
|
+
except Exception as e:
|
|
304
|
+
console.print(f"[bold red]Download Error:[/bold red] {e}")
|
|
305
|
+
|
|
306
|
+
@app.command(short_help="View and manage your offline favorites")
|
|
307
|
+
def show_fav():
|
|
308
|
+
"""Lists all structured data in the favorites NoSQL box."""
|
|
309
|
+
favs = fav_table.all()
|
|
310
|
+
if not favs:
|
|
311
|
+
console.print("[dim]No offline songs found. Try 'add-fav <VideoID>'[/dim]")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
table = Table(title="OFFLINE FAVORITES", box=box.HEAVY_EDGE)
|
|
315
|
+
table.add_column("No.", style="dim")
|
|
316
|
+
table.add_column("Song Title", style="bold white")
|
|
317
|
+
table.add_column("Artist", style="cyan")
|
|
318
|
+
table.add_column("Video ID", style="green")
|
|
319
|
+
|
|
320
|
+
for i, song in enumerate(favs, start=1):
|
|
321
|
+
table.add_row(str(i), song['title'], song['artist'], song['video_id'])
|
|
322
|
+
|
|
323
|
+
console.print(table, justify="center")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command(short_help="show help")
|
|
328
|
+
def help():
|
|
329
|
+
table = Table(show_header=True, header_style="bold blue", box=box.ROUNDED)
|
|
330
|
+
table.add_column("Command", width=14, style="cyan")
|
|
331
|
+
table.add_column("Description", style="dim", width=50)
|
|
332
|
+
table.add_row("search", "Search for a song")
|
|
333
|
+
table.add_row("play", "Play a song")
|
|
334
|
+
table.add_row("add-fav", "Add a song to favorites for offline playback")
|
|
335
|
+
table.add_row("show-fav", "Show offline favorite songs")
|
|
336
|
+
table.add_row("delete-fav", "Delete a song from offline favorites")
|
|
337
|
+
table.add_row("show-history", "Show the play history")
|
|
338
|
+
table.add_row("clear-history", "Clear the play history")
|
|
339
|
+
table.add_row("setup", "setup the environment to global")
|
|
340
|
+
|
|
341
|
+
console.print(
|
|
342
|
+
Panel(
|
|
343
|
+
Align.center(
|
|
344
|
+
"""[bold green]
|
|
345
|
+
░██████╗██████╗░░█████╗░██╗
|
|
346
|
+
██╔════╝██╔══██╗██╔══██╗██║
|
|
347
|
+
╚█████╗░██████╔╝██║░░╚═╝██║
|
|
348
|
+
░╚═══██╗██╔═══╝░██║░░██╗██║
|
|
349
|
+
██████╔╝██║░░░░░╚█████╔╝██║
|
|
350
|
+
╚═════╝░╚═╝░░░░░░╚════╝░╚═╝
|
|
351
|
+
[/bold green]
|
|
352
|
+
[dim]Sonic Pulse Command Interface[/dim]
|
|
353
|
+
[dim]A simple yet elegant CLI music player[/dim]
|
|
354
|
+
""",
|
|
355
|
+
vertical="middle"
|
|
356
|
+
),
|
|
357
|
+
box=box.DOUBLE,
|
|
358
|
+
style="green",
|
|
359
|
+
subtitle="Welcome"
|
|
360
|
+
),
|
|
361
|
+
Align.right("""developed by [bold blue][link=https://github.com/ojaswi1234]@ojaswi1234[/link][/bold blue]""")
|
|
362
|
+
)
|
|
363
|
+
console.print(table, justify="center")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command(short_help="Find music online")
|
|
367
|
+
def search(query: str):
|
|
368
|
+
"""Searches Online and displays results with their unique IDs."""
|
|
369
|
+
with console.status(f"[bold green]Searching music online '{query}'...[/bold green]"):
|
|
370
|
+
results = get_music(query)
|
|
371
|
+
|
|
372
|
+
if results:
|
|
373
|
+
table = Table(title=f"YouTube Results for: {query}", box=box.MINIMAL_DOUBLE_HEAD)
|
|
374
|
+
table.add_column("ID", style="green")
|
|
375
|
+
table.add_column("Title", style="bold white")
|
|
376
|
+
table.add_column("Channel/Artist", style="cyan")
|
|
377
|
+
table.add_column("Length", justify="right")
|
|
378
|
+
|
|
379
|
+
for song in results:
|
|
380
|
+
table.add_row(song['videoId'], song['title'], song['artists'], song['duration'])
|
|
381
|
+
console.print(table, justify="center")
|
|
382
|
+
else:
|
|
383
|
+
console.print("[bold red]No results found.[/bold red]")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@app.command(short_help="Play a song (Checks offline first)")
|
|
389
|
+
def play(query: str):
|
|
390
|
+
"""Handles playback with robust variable initialization to prevent crashes."""
|
|
391
|
+
Song = Query()
|
|
392
|
+
offline_entry = fav_table.get((Song.video_id == query) | (Song.title == query))
|
|
393
|
+
|
|
394
|
+
# 1. INITIALIZE VARIABLES (Prevents UnboundLocalError)
|
|
395
|
+
title = "Unknown Title"
|
|
396
|
+
artist = "Unknown Artist"
|
|
397
|
+
vid = query
|
|
398
|
+
audio_source = None
|
|
399
|
+
is_offline = False
|
|
400
|
+
|
|
401
|
+
if offline_entry:
|
|
402
|
+
# Check for the file (supports multiple extensions for mpv)
|
|
403
|
+
base_path = os.path.splitext(offline_entry['path'])[0]
|
|
404
|
+
for ext in ['.webm', '.m4a', '.mp3', '.opus']:
|
|
405
|
+
test_path = base_path + ext
|
|
406
|
+
if os.path.exists(test_path):
|
|
407
|
+
audio_source = test_path
|
|
408
|
+
title = offline_entry.get('title', 'Unknown')
|
|
409
|
+
artist = offline_entry.get('artist', 'Unknown')
|
|
410
|
+
vid = offline_entry.get('video_id', query)
|
|
411
|
+
is_offline = True
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
if not is_offline:
|
|
415
|
+
# 2. ONLINE FALLBACK
|
|
416
|
+
try:
|
|
417
|
+
with console.status(f"[bold green]Searching online for '{query}'...[/bold green]"):
|
|
418
|
+
results = get_music(query)
|
|
419
|
+
if not results:
|
|
420
|
+
return console.print("[bold red]Song not found offline or online.[/bold red]")
|
|
421
|
+
|
|
422
|
+
song = results[0]
|
|
423
|
+
vid, title, artist = song['videoId'], song['title'], song['artists']
|
|
424
|
+
|
|
425
|
+
# Double check search result against DB
|
|
426
|
+
second_check = fav_table.get(Song.video_id == vid)
|
|
427
|
+
if second_check and os.path.exists(second_check['path']):
|
|
428
|
+
audio_source, is_offline = second_check['path'], True
|
|
429
|
+
else:
|
|
430
|
+
# Stream 64kbps to save bandwidth
|
|
431
|
+
ydl_opts = {'format': 'bestaudio[abr<=64]/bestaudio/best', 'quiet': True}
|
|
432
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
433
|
+
info = ydl.extract_info(f"https://www.youtube.com/watch?v={vid}", download=False)
|
|
434
|
+
audio_source = info.get('url')
|
|
435
|
+
is_offline = False
|
|
436
|
+
except Exception:
|
|
437
|
+
return console.print("[bold red]Error:[/bold red] Check internet or favorites.")
|
|
438
|
+
|
|
439
|
+
# 3. LOG HISTORY (Variables are now guaranteed to exist)
|
|
440
|
+
log_history(title, vid)
|
|
441
|
+
|
|
442
|
+
# UI EXECUTION
|
|
443
|
+
layout = make_layout()
|
|
444
|
+
try:
|
|
445
|
+
with Live(layout, refresh_per_second=20, screen=True):
|
|
446
|
+
# Uses mpv or ffplay based on OS detection
|
|
447
|
+
process = subprocess.Popen(get_player_command() + [audio_source],
|
|
448
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
449
|
+
t = 0.0
|
|
450
|
+
while process.poll() is None:
|
|
451
|
+
layout["header"].update(get_header())
|
|
452
|
+
layout["left"].update(get_now_playing_panel(title, artist, is_offline, t))
|
|
453
|
+
layout["right"].update(get_stats_panel())
|
|
454
|
+
layout["footer"].update(get_controls_panel())
|
|
455
|
+
t += 0.12
|
|
456
|
+
time.sleep(0.05)
|
|
457
|
+
except KeyboardInterrupt:
|
|
458
|
+
process.terminate()
|
|
459
|
+
|
|
460
|
+
@app.command(short_help="Remove a song from your offline favorites")
|
|
461
|
+
def delete_fav(video_id: str):
|
|
462
|
+
"""Deletes the local audio file and removes metadata from TinyDB."""
|
|
463
|
+
Song = Query()
|
|
464
|
+
item = fav_table.get(Song.video_id == video_id)
|
|
465
|
+
|
|
466
|
+
if not item:
|
|
467
|
+
console.print(f"[bold red]Error:[/bold red] Video ID '{video_id}' not found in favorites.")
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
# 1. Delete the physical file
|
|
471
|
+
file_path = item.get('path')
|
|
472
|
+
try:
|
|
473
|
+
if file_path and os.path.exists(file_path):
|
|
474
|
+
os.remove(file_path)
|
|
475
|
+
console.print(f"[dim]Physical file removed: {video_id}.mp3[/dim]")
|
|
476
|
+
except Exception as e:
|
|
477
|
+
console.print(f"[bold yellow]Warning:[/bold yellow] Could not delete file: {e}")
|
|
478
|
+
|
|
479
|
+
# 2. Remove from NoSQL Database
|
|
480
|
+
fav_table.remove(Song.video_id == video_id)
|
|
481
|
+
console.print(f"[bold green]Deleted![/bold green] '{item['title']}' has been removed from SPCI.")
|
|
482
|
+
|
|
483
|
+
@app.command()
|
|
484
|
+
def show_history():
|
|
485
|
+
if os.path.exists(HISTORY_FILE):
|
|
486
|
+
with open(HISTORY_FILE) as f:
|
|
487
|
+
history_text = f.read()
|
|
488
|
+
|
|
489
|
+
panel = Panel(
|
|
490
|
+
history_text,
|
|
491
|
+
title="[bold blue]Play History[/bold blue]",
|
|
492
|
+
border_style="blue",
|
|
493
|
+
box=box.ROUNDED
|
|
494
|
+
)
|
|
495
|
+
console.print(panel)
|
|
496
|
+
else:
|
|
497
|
+
console.print("[dim]No history found.[/dim]")
|
|
498
|
+
|
|
499
|
+
@app.command()
|
|
500
|
+
def clear_history():
|
|
501
|
+
if os.path.exists(HISTORY_FILE):
|
|
502
|
+
os.remove(HISTORY_FILE)
|
|
503
|
+
console.print("[bold green]History cleared.[/bold green]")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == "__main__":
|
|
508
|
+
app()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spci-sonic-pulse
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Requires-Dist: typer
|
|
5
|
+
Requires-Dist: rich
|
|
6
|
+
Requires-Dist: requests
|
|
7
|
+
Requires-Dist: yt-dlp
|
|
8
|
+
Requires-Dist: python-vlc
|
|
9
|
+
Requires-Dist: ytmusicapi
|
|
10
|
+
Requires-Dist: tinydb
|
|
11
|
+
Dynamic: requires-dist
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
spci/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
spci/getmusic.py,sha256=4IOfsWqVxmTpV2yfhgwxlsBn91os_iM-ibLXFLO3c6c,1925
|
|
3
|
+
spci/mp.py,sha256=q6bXuAz-BqAqnD5ekQvHgUqEeFg-OZEdqlwS-3qCepU,20103
|
|
4
|
+
spci_sonic_pulse-2.0.1.dist-info/METADATA,sha256=H-aKF5dc5E37yM46h4ylR4M5Hdwfd4xYCMoyXFVW0s4,255
|
|
5
|
+
spci_sonic_pulse-2.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
spci_sonic_pulse-2.0.1.dist-info/entry_points.txt,sha256=Jhr15wSXKlpmphjf6NlRFdDoAMBtKIjhf8HvIrdM6O0,37
|
|
7
|
+
spci_sonic_pulse-2.0.1.dist-info/top_level.txt,sha256=hx42bb8jVcX4BeZkgkaTpcA5Lh9x3ONYicda0hyczxU,5
|
|
8
|
+
spci_sonic_pulse-2.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spci
|