slopatronic 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.
- slopatronic-0.1.0/PKG-INFO +12 -0
- slopatronic-0.1.0/README.md +55 -0
- slopatronic-0.1.0/__init__.py +1 -0
- slopatronic-0.1.0/__main__.py +3 -0
- slopatronic-0.1.0/cli.py +171 -0
- slopatronic-0.1.0/client.py +49 -0
- slopatronic-0.1.0/config.py +72 -0
- slopatronic-0.1.0/daemon.py +189 -0
- slopatronic-0.1.0/downloader.py +114 -0
- slopatronic-0.1.0/folders.py +93 -0
- slopatronic-0.1.0/library.py +104 -0
- slopatronic-0.1.0/loudness.py +75 -0
- slopatronic-0.1.0/player.py +128 -0
- slopatronic-0.1.0/pyproject.toml +27 -0
- slopatronic-0.1.0/setup.cfg +4 -0
- slopatronic-0.1.0/slopatronic.egg-info/PKG-INFO +12 -0
- slopatronic-0.1.0/slopatronic.egg-info/SOURCES.txt +32 -0
- slopatronic-0.1.0/slopatronic.egg-info/dependency_links.txt +1 -0
- slopatronic-0.1.0/slopatronic.egg-info/entry_points.txt +2 -0
- slopatronic-0.1.0/slopatronic.egg-info/requires.txt +7 -0
- slopatronic-0.1.0/slopatronic.egg-info/top_level.txt +1 -0
- slopatronic-0.1.0/ui.py +1233 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slopatronic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find music on YouTube (audio only), keep a library, and play it in the background.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: textual>=0.60
|
|
7
|
+
Requires-Dist: rich>=13.0
|
|
8
|
+
Requires-Dist: rapidfuzz
|
|
9
|
+
Requires-Dist: yt-dlp
|
|
10
|
+
Requires-Dist: imageio-ffmpeg
|
|
11
|
+
Requires-Dist: miniaudio
|
|
12
|
+
Requires-Dist: questionary
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# slopatronic
|
|
2
|
+
|
|
3
|
+
Terminal music player that finds tracks on YouTube, Bandcamp and Mixcloud, downloads the audio, and keeps playing in the background while you do other things.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires Python 3.10+ (ffmpeg is bundled via `imageio-ffmpeg`).
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install -r requirements.txt
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
On Windows, use `slopatronic.cmd` from the parent directory (or set `PYTHONPATH` yourself). On Linux/macOS, run from the parent directory:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
python -m slopatronic
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
slopatronic find <query> # search all sources, tick what you want, download audio only
|
|
23
|
+
slopatronic list # show downloaded tracks
|
|
24
|
+
slopatronic add <query> # fuzzy-match a track and append it to the queue
|
|
25
|
+
slopatronic play <query> # play now, replacing whatever is on
|
|
26
|
+
slopatronic pause / next / prev / stop
|
|
27
|
+
slopatronic queue # show the queue with a live progress view
|
|
28
|
+
slopatronic ui # full TUI
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`<query>` can be a library index number, a fuzzy match, or a search string.
|
|
32
|
+
|
|
33
|
+
### TUI
|
|
34
|
+
|
|
35
|
+
`slopatronic ui` opens a Textual app with three tabs: search results, library tree, and now playing (queue, controls, volume, a live level meter). There's a command mode and a filter mode; tables are clickable.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
- A detached daemon owns the queue and decodes tracks with ffmpeg to raw PCM, streamed through miniaudio. CLI commands talk to it over a local socket (port 47651), so music keeps playing after the terminal closes.
|
|
40
|
+
- Downloads go to `~/.slopatronic` by default. No re-encoding — the best audio stream is saved as-is.
|
|
41
|
+
- You can register extra music folders in `~/.slopatronic/config.json` (with optional recursive scanning); your own files are never touched.
|
|
42
|
+
- Tracks are loudness-normalized on the fly: each queued track is measured with ebur128, and a gain is applied to hit the target in config (default -12 LUFS), capped so loud sections never exceed a -6 LUFS ceiling. Gains live in memory only.
|
|
43
|
+
|
|
44
|
+
## Config
|
|
45
|
+
|
|
46
|
+
`~/.slopatronic/config.json`, created on first run:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"folders": [{"path": "/abs/dir", "recursive": false}],
|
|
51
|
+
"volume": 1.0,
|
|
52
|
+
"loudness_target": -12.0,
|
|
53
|
+
"loudness_correction": true
|
|
54
|
+
}
|
|
55
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Find music on YouTube (audio only), keep a library, and play it in the background."""
|
slopatronic-0.1.0/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import questionary
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from .client import ensure_daemon, request
|
|
8
|
+
from .downloader import download, search_all
|
|
9
|
+
from .library import fuzzy_paths, library_files, library_tree
|
|
10
|
+
from .ui import console, queue_table, run_ui
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pick_youtube(query):
|
|
14
|
+
"""Search every source (shared search_all), print results grouped by
|
|
15
|
+
source with per-source failure reasons, then let the user tick tracks
|
|
16
|
+
to download (the original checkbox picker)."""
|
|
17
|
+
flat = [] # (source label, entry) across all sources, for the picker
|
|
18
|
+
for source, entries, error in search_all(query):
|
|
19
|
+
if error:
|
|
20
|
+
console.print(f"[red]{source}: search failed — "
|
|
21
|
+
f"{type(error).__name__}: {error}[/]")
|
|
22
|
+
continue
|
|
23
|
+
console.print(f"[green]{source}: {len(entries)} results:[/]")
|
|
24
|
+
for i, entry in enumerate(entries, 1):
|
|
25
|
+
title = entry.get("title", "untitled")
|
|
26
|
+
url = entry.get("url") or entry.get("id") or ""
|
|
27
|
+
console.print(f" {i:2d}. {title} [dim]{url}[/]")
|
|
28
|
+
flat.append((source, entry))
|
|
29
|
+
if not flat:
|
|
30
|
+
console.print("[red]No results.[/]")
|
|
31
|
+
return []
|
|
32
|
+
picked = questionary.checkbox(
|
|
33
|
+
"Tick tracks with space, then Enter to download:",
|
|
34
|
+
choices=[questionary.Choice(f"[{source}] {e.get('title', 'untitled')}",
|
|
35
|
+
value=e)
|
|
36
|
+
for source, e in flat],
|
|
37
|
+
use_search_filter=True,
|
|
38
|
+
use_jk_keys=False,
|
|
39
|
+
).ask()
|
|
40
|
+
if picked is None:
|
|
41
|
+
return []
|
|
42
|
+
if not picked:
|
|
43
|
+
console.print("[dim]Nothing ticked. Press space on a track to tick it, then Enter.[/]")
|
|
44
|
+
return picked
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fetch(entry):
|
|
48
|
+
"""Download one search result into the library. Returns the path, or None on failure."""
|
|
49
|
+
title = entry.get("title", "untitled")
|
|
50
|
+
try:
|
|
51
|
+
with console.status(f"Downloading {title}..."):
|
|
52
|
+
path, outcome = download(entry)
|
|
53
|
+
except Exception as exc: # one failed track should not stop the rest
|
|
54
|
+
console.print(f" [red]✘[/] {title}: {exc}")
|
|
55
|
+
return None
|
|
56
|
+
console.print(f" [green]✔[/] {title} [dim]({outcome})[/]")
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pick_library_track(query):
|
|
61
|
+
"""Resolve a query to one library track: a number, a fuzzy match, or the best hit."""
|
|
62
|
+
files = library_files()
|
|
63
|
+
if not files:
|
|
64
|
+
console.print("[dim]Library is empty. Use find first.[/]")
|
|
65
|
+
return None
|
|
66
|
+
if query.isdigit() and 1 <= int(query) <= len(files):
|
|
67
|
+
return files[int(query) - 1]
|
|
68
|
+
candidates = fuzzy_paths(query, files) if query else files
|
|
69
|
+
if not candidates:
|
|
70
|
+
console.print(f"[red]No library match for '{query}'.[/] Try: find {query}")
|
|
71
|
+
return None
|
|
72
|
+
return candidates[0] # best fuzzy hit
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_find(args):
|
|
76
|
+
for entry in pick_youtube(" ".join(args.query)):
|
|
77
|
+
fetch(entry)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_add(args):
|
|
81
|
+
chosen = pick_library_track(" ".join(args.query).strip())
|
|
82
|
+
if chosen is None:
|
|
83
|
+
return
|
|
84
|
+
ensure_daemon()
|
|
85
|
+
if request("add", paths=[str(chosen)]) is None:
|
|
86
|
+
console.print("[red]The player did not respond. Try again.[/]")
|
|
87
|
+
return
|
|
88
|
+
console.print(f"Queued: {chosen.stem}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_play(args):
|
|
92
|
+
chosen = pick_library_track(" ".join(args.query).strip())
|
|
93
|
+
if chosen is None:
|
|
94
|
+
return
|
|
95
|
+
ensure_daemon()
|
|
96
|
+
response = request("play", path=str(chosen))
|
|
97
|
+
if response is None or "error" in response:
|
|
98
|
+
reason = response.get("error", "") if response else "no response"
|
|
99
|
+
console.print(f"[red]Could not play {chosen.stem}:[/] {reason}")
|
|
100
|
+
return
|
|
101
|
+
console.print(f"Playing: {chosen.stem}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_list(_args):
|
|
105
|
+
# same tree the TUI Library tab shows: folder roots, subfolders, tracks
|
|
106
|
+
rows = library_tree()
|
|
107
|
+
files = [p for kind, _d, _n, p in rows if kind == "file"]
|
|
108
|
+
if not files:
|
|
109
|
+
console.print("[dim]No music yet. Use find first.[/]")
|
|
110
|
+
return
|
|
111
|
+
numbers = iter(range(1, len(files) + 1))
|
|
112
|
+
for kind, depth, name, path in rows:
|
|
113
|
+
if kind == "dir":
|
|
114
|
+
console.print(f"{' ' * depth}[bold]{name}/[/]")
|
|
115
|
+
else:
|
|
116
|
+
console.print(f"{' ' * depth}[dim]{next(numbers):>3d}.[/] {name}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_queue(_args):
|
|
120
|
+
st = request("status")
|
|
121
|
+
if st is None or not st["active"]:
|
|
122
|
+
console.print("[dim]Nothing playing.[/]")
|
|
123
|
+
return
|
|
124
|
+
console.print(queue_table(st))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_ui(_args):
|
|
128
|
+
run_ui()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def control(cmd):
|
|
132
|
+
def run(_args):
|
|
133
|
+
if request(cmd) is None:
|
|
134
|
+
console.print("[dim]Nothing playing.[/]")
|
|
135
|
+
return run
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_stop(_args):
|
|
139
|
+
if request("stop") is None:
|
|
140
|
+
console.print("[dim]The player is not running.[/]")
|
|
141
|
+
return
|
|
142
|
+
deadline = time.time() + 3 # wait for the port to close so the next command starts a fresh player
|
|
143
|
+
while time.time() < deadline and request("status") is not None:
|
|
144
|
+
time.sleep(0.1)
|
|
145
|
+
console.print("Player stopped.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main():
|
|
149
|
+
parser = argparse.ArgumentParser(description="Find music on YouTube (audio only) and play it in the background.")
|
|
150
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
151
|
+
|
|
152
|
+
commands = {
|
|
153
|
+
"find": (cmd_find, "search YouTube and download audio only", "+"),
|
|
154
|
+
"add": (cmd_add, "add a library track to the end of the queue", "*"),
|
|
155
|
+
"play": (cmd_play, "play a library track now, replacing the current one", "*"),
|
|
156
|
+
"list": (cmd_list, "list downloaded tracks", None),
|
|
157
|
+
"queue": (cmd_queue, "show the current queue", None),
|
|
158
|
+
"ui": (cmd_ui, "full TUI: search, download, queue and player controls", None),
|
|
159
|
+
"pause": (control("pause"), "pause or resume", None),
|
|
160
|
+
"next": (control("next"), "skip to the next track", None),
|
|
161
|
+
"prev": (control("prev"), "go back to the previous track", None),
|
|
162
|
+
"stop": (cmd_stop, "stop the background player", None),
|
|
163
|
+
}
|
|
164
|
+
for name, (func, help_text, nargs) in commands.items():
|
|
165
|
+
p = sub.add_parser(name, help=help_text)
|
|
166
|
+
if nargs:
|
|
167
|
+
p.add_argument("query", nargs=nargs)
|
|
168
|
+
p.set_defaults(func=func)
|
|
169
|
+
|
|
170
|
+
args = parser.parse_args()
|
|
171
|
+
args.func(args)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .config import HOST, PACKAGE_PARENT, PORT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def request(cmd, _timeout=2, **kwargs):
|
|
11
|
+
"""Send one command to the background player. Returns None if it is not running."""
|
|
12
|
+
payload = json.dumps({"cmd": cmd, **kwargs}) + "\n"
|
|
13
|
+
try:
|
|
14
|
+
with socket.create_connection((HOST, PORT), timeout=_timeout) as sock:
|
|
15
|
+
sock.sendall(payload.encode())
|
|
16
|
+
line = sock.makefile("rb").readline()
|
|
17
|
+
return json.loads(line)
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _spawn_detached():
|
|
23
|
+
"""Start a detached background process on Windows or POSIX."""
|
|
24
|
+
kwargs = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
|
|
25
|
+
"stderr": subprocess.DEVNULL, "close_fds": True}
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
kwargs["creationflags"] = (
|
|
28
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
kwargs["start_new_session"] = True
|
|
32
|
+
return subprocess.Popen(
|
|
33
|
+
[sys.executable, "-c", "from slopatronic.daemon import serve; serve()"],
|
|
34
|
+
cwd=PACKAGE_PARENT,
|
|
35
|
+
**kwargs,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_daemon():
|
|
40
|
+
"""Start the background player if it is not already running."""
|
|
41
|
+
if request("status") is not None:
|
|
42
|
+
return
|
|
43
|
+
_spawn_detached()
|
|
44
|
+
deadline = time.time() + 5
|
|
45
|
+
while time.time() < deadline:
|
|
46
|
+
if request("status") is not None:
|
|
47
|
+
return
|
|
48
|
+
time.sleep(0.1)
|
|
49
|
+
raise SystemExit("Could not start the background player.")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# Everything lives in one hidden folder; the config is one file inside it.
|
|
5
|
+
LIBRARY = Path.home() / ".slopatronic"
|
|
6
|
+
CONFIG_FILE = LIBRARY / "config.json"
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG = {
|
|
9
|
+
"folders": [], # extra library folders: [{"path": ..., "recursive": ...}]
|
|
10
|
+
"volume": 1.0, # remembered player volume (0..1)
|
|
11
|
+
"loudness_target": -12.0, # common-sense target loudness (LUFS) tracks are adjusted to
|
|
12
|
+
"loudness_correction": True, # toggle: adjust track volume to the loudness target
|
|
13
|
+
}
|
|
14
|
+
EXTENSIONS = {".m4a", ".webm", ".opus", ".mp3", ".ogg", ".wav", ".flac"}
|
|
15
|
+
|
|
16
|
+
# The background player listens on this local port.
|
|
17
|
+
HOST = "127.0.0.1"
|
|
18
|
+
PORT = 47651
|
|
19
|
+
|
|
20
|
+
SAMPLE_RATE = 44100
|
|
21
|
+
CHANNELS = 2
|
|
22
|
+
FRAMES_PER_SECOND = SAMPLE_RATE * CHANNELS
|
|
23
|
+
|
|
24
|
+
METER_BANDS = 24
|
|
25
|
+
METER_FULL_SCALE = 12000
|
|
26
|
+
BAR_CHARS = "▁▂▃▄▅▆▇█"
|
|
27
|
+
|
|
28
|
+
# Folder that contains the slopatronic package, used to start the player in the background.
|
|
29
|
+
PACKAGE_PARENT = Path(__file__).resolve().parents[1]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_config():
|
|
33
|
+
"""Always make sure the config file exists (even if empty)."""
|
|
34
|
+
LIBRARY.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
if not CONFIG_FILE.exists():
|
|
36
|
+
save_config(dict(DEFAULT_CONFIG))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config():
|
|
40
|
+
"""The config dict, merged over the defaults; generates the file if needed."""
|
|
41
|
+
ensure_config()
|
|
42
|
+
data = dict(DEFAULT_CONFIG)
|
|
43
|
+
try:
|
|
44
|
+
data.update(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
pass
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_config(data):
|
|
51
|
+
LIBRARY.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_volume():
|
|
56
|
+
return min(1.0, max(0.0, float(load_config()["volume"])))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_volume(volume):
|
|
60
|
+
data = load_config()
|
|
61
|
+
data["volume"] = round(min(1.0, max(0.0, float(volume))), 3)
|
|
62
|
+
save_config(data)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_target():
|
|
66
|
+
"""The common-sense loudness target (LUFS) from the config file."""
|
|
67
|
+
return float(load_config()["loudness_target"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def correction_enabled():
|
|
71
|
+
"""Whether loudness correction is toggled on in the config file."""
|
|
72
|
+
return bool(load_config()["loudness_correction"])
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socketserver
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import HOST, PORT, correction_enabled, load_volume, save_volume
|
|
8
|
+
from .loudness import gain_multiplier, measure_gain
|
|
9
|
+
from .player import Player
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Daemon:
|
|
13
|
+
"""Owns the queue and the player. Requests come in over a local socket."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.lock = threading.RLock()
|
|
17
|
+
self.queue = []
|
|
18
|
+
self.index = 0
|
|
19
|
+
self.player = Player()
|
|
20
|
+
# the knob; per-track normalization gain is separate
|
|
21
|
+
self.user_volume = load_volume()
|
|
22
|
+
self.player.set_volume(self.user_volume)
|
|
23
|
+
# in-memory loudness gains (dB) per queued track path, measured when
|
|
24
|
+
# a track is queued; never written to disk, gone when the daemon stops
|
|
25
|
+
self.gains = {}
|
|
26
|
+
self.server = None
|
|
27
|
+
self.stopping = threading.Event()
|
|
28
|
+
|
|
29
|
+
def _measure_async(self, paths):
|
|
30
|
+
"""Measure queued tracks in the background and cache the gain in
|
|
31
|
+
memory; if one of them is already playing, apply it as soon as known.
|
|
32
|
+
Skipped entirely when the loudness_correction toggle is off."""
|
|
33
|
+
if not correction_enabled():
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
def work():
|
|
37
|
+
for path in paths:
|
|
38
|
+
gain = measure_gain(path)
|
|
39
|
+
with self.lock:
|
|
40
|
+
self.gains[str(path)] = gain or 0.0
|
|
41
|
+
if (self.player.loaded and self.index < len(self.queue)
|
|
42
|
+
and str(self.queue[self.index]) == str(path)):
|
|
43
|
+
self.player.set_gain(gain_multiplier(gain))
|
|
44
|
+
threading.Thread(target=work, daemon=True).start()
|
|
45
|
+
|
|
46
|
+
def _load_current(self):
|
|
47
|
+
self.player.load(self.queue[self.index])
|
|
48
|
+
# passive volume normalization: the in-memory gain measured at queue
|
|
49
|
+
# time, applied on top of the user's volume knob; neutral unless the
|
|
50
|
+
# loudness_correction toggle is on and a measured gain exists
|
|
51
|
+
gain = self.gains.get(str(self.queue[self.index]), 0.0) if correction_enabled() else 0.0
|
|
52
|
+
self.player.set_gain(gain_multiplier(gain))
|
|
53
|
+
|
|
54
|
+
def _go_idle(self):
|
|
55
|
+
self.player.stop()
|
|
56
|
+
self.queue = []
|
|
57
|
+
self.index = 0
|
|
58
|
+
self.gains.clear()
|
|
59
|
+
|
|
60
|
+
def _advance(self):
|
|
61
|
+
if self.index + 1 < len(self.queue):
|
|
62
|
+
self.index += 1
|
|
63
|
+
self._load_current()
|
|
64
|
+
else:
|
|
65
|
+
self._go_idle()
|
|
66
|
+
|
|
67
|
+
def status(self):
|
|
68
|
+
if not self.player.loaded:
|
|
69
|
+
return {"active": False, "queue": [], "index": 0, "paused": False,
|
|
70
|
+
"elapsed": 0, "duration": 0, "meter": "", "volume": self.player.volume}
|
|
71
|
+
return {
|
|
72
|
+
"active": True,
|
|
73
|
+
"queue": [p.stem for p in self.queue],
|
|
74
|
+
# clamp defensively: no client must ever see an out-of-range index
|
|
75
|
+
"index": min(self.index, len(self.queue) - 1),
|
|
76
|
+
"paused": self.player.paused,
|
|
77
|
+
"elapsed": self.player.elapsed(),
|
|
78
|
+
"duration": self.player.duration(),
|
|
79
|
+
"meter": self.player.meter(),
|
|
80
|
+
"volume": self.user_volume,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def handle(self, req):
|
|
84
|
+
cmd = req.get("cmd")
|
|
85
|
+
with self.lock:
|
|
86
|
+
if cmd == "status":
|
|
87
|
+
pass
|
|
88
|
+
elif cmd == "add":
|
|
89
|
+
paths = [Path(p) for p in req["paths"]]
|
|
90
|
+
if paths and not self.player.loaded:
|
|
91
|
+
self.queue = paths
|
|
92
|
+
self.index = 0
|
|
93
|
+
self._load_current()
|
|
94
|
+
else:
|
|
95
|
+
self.queue.extend(paths)
|
|
96
|
+
self._measure_async(paths)
|
|
97
|
+
elif cmd == "remove":
|
|
98
|
+
# drop one queued track; removing the currently playing one
|
|
99
|
+
# skips straight to the track that takes its slot
|
|
100
|
+
idx = int(req["index"])
|
|
101
|
+
if not (0 <= idx < len(self.queue)):
|
|
102
|
+
return {"error": "no such queue index"}
|
|
103
|
+
popped = self.queue.pop(idx)
|
|
104
|
+
self.gains.pop(str(popped), None)
|
|
105
|
+
if idx < self.index:
|
|
106
|
+
self.index -= 1
|
|
107
|
+
elif idx == self.index:
|
|
108
|
+
if not self.queue or idx == len(self.queue):
|
|
109
|
+
# removed the playing track and nothing follows it
|
|
110
|
+
# (queue drained, or it was the last element):
|
|
111
|
+
# nothing more to play — stop and clear, exactly like
|
|
112
|
+
# a natural end-of-queue
|
|
113
|
+
self._go_idle()
|
|
114
|
+
else:
|
|
115
|
+
self._load_current() # play the new occupant of the slot
|
|
116
|
+
elif self.index >= len(self.queue):
|
|
117
|
+
self.index = max(0, len(self.queue) - 1)
|
|
118
|
+
elif cmd == "play":
|
|
119
|
+
# playskip: the chosen track replaces the current one and starts now.
|
|
120
|
+
path = Path(req["path"])
|
|
121
|
+
if self.player.loaded:
|
|
122
|
+
self.queue[self.index] = path
|
|
123
|
+
else:
|
|
124
|
+
self.queue = [path]
|
|
125
|
+
self.index = 0
|
|
126
|
+
self._load_current()
|
|
127
|
+
self._measure_async([path])
|
|
128
|
+
elif cmd == "pause" and self.player.loaded:
|
|
129
|
+
self.player.toggle_pause()
|
|
130
|
+
elif cmd == "next" and self.player.loaded:
|
|
131
|
+
self._advance()
|
|
132
|
+
elif cmd == "prev" and self.player.loaded and self.index > 0:
|
|
133
|
+
self.index -= 1
|
|
134
|
+
self._load_current()
|
|
135
|
+
elif cmd == "seek" and self.player.loaded:
|
|
136
|
+
self.player.seek(float(req["seconds"]))
|
|
137
|
+
elif cmd == "volume":
|
|
138
|
+
self.user_volume = min(1.0, max(0.0, self.user_volume + float(req["delta"])))
|
|
139
|
+
self.player.set_volume(self.user_volume)
|
|
140
|
+
save_volume(self.user_volume) # remembered for the next start
|
|
141
|
+
elif cmd == "stop":
|
|
142
|
+
self._go_idle()
|
|
143
|
+
self.stopping.set()
|
|
144
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
145
|
+
return {"ok": True}
|
|
146
|
+
else:
|
|
147
|
+
return {"error": f"unknown or inapplicable command: {cmd}"}
|
|
148
|
+
return self.status()
|
|
149
|
+
|
|
150
|
+
def monitor(self):
|
|
151
|
+
"""Advances to the next track when the current one has finished."""
|
|
152
|
+
while not self.stopping.is_set():
|
|
153
|
+
time.sleep(0.2)
|
|
154
|
+
with self.lock:
|
|
155
|
+
done = self.player.loaded and self.player.finished()
|
|
156
|
+
if done:
|
|
157
|
+
time.sleep(0.5) # let the device drain its buffer
|
|
158
|
+
with self.lock:
|
|
159
|
+
if self.player.loaded and self.player.finished():
|
|
160
|
+
self._advance()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Handler(socketserver.StreamRequestHandler):
|
|
164
|
+
def handle(self):
|
|
165
|
+
line = self.rfile.readline()
|
|
166
|
+
if not line:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
response = self.server.state.handle(json.loads(line))
|
|
170
|
+
except Exception as exc: # a bad request must not take the player down
|
|
171
|
+
response = {"error": str(exc)}
|
|
172
|
+
self.wfile.write((json.dumps(response) + "\n").encode())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _Server(socketserver.ThreadingTCPServer):
|
|
176
|
+
daemon_threads = True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def serve():
|
|
180
|
+
state = Daemon()
|
|
181
|
+
server = _Server((HOST, PORT), _Handler)
|
|
182
|
+
server.state = state
|
|
183
|
+
state.server = server
|
|
184
|
+
threading.Thread(target=state.monitor, daemon=True).start()
|
|
185
|
+
try:
|
|
186
|
+
server.serve_forever()
|
|
187
|
+
finally:
|
|
188
|
+
server.server_close()
|
|
189
|
+
state.player.close()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import urllib.request
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import imageio_ffmpeg
|
|
8
|
+
from yt_dlp import YoutubeDL
|
|
9
|
+
|
|
10
|
+
from .config import LIBRARY
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def search(query, limit=10, prefix="ytsearch"):
|
|
14
|
+
"""Search via any yt-dlp search prefix (ytsearch, scsearch, ...)."""
|
|
15
|
+
opts = {"quiet": True, "skip_download": True, "extract_flat": "in_playlist"}
|
|
16
|
+
with YoutubeDL(opts) as ydl:
|
|
17
|
+
info = ydl.extract_info(f"{prefix}{limit}:{query}", download=False)
|
|
18
|
+
return [e for e in info.get("entries", []) if e]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _json_url(url, timeout=8):
|
|
22
|
+
"""Small helper: GET a JSON endpoint with a browser-ish User-Agent."""
|
|
23
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
24
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
25
|
+
return json.loads(resp.read().decode("utf-8", "replace"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bandcamp_search(query, limit=10):
|
|
29
|
+
"""Bandcamp has no yt-dlp search prefix; use the same autocomplete_elastic
|
|
30
|
+
endpoint the Bandcamp website search box calls from the browser (POST,
|
|
31
|
+
no API key, not bot-blocked)."""
|
|
32
|
+
body = json.dumps({"search_text": query, "search_filter": "t",
|
|
33
|
+
"full_page": False, "fan_id": None}).encode()
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
"https://bandcamp.com/api/bcsearch_public_api/1/autocomplete_elastic",
|
|
36
|
+
data=body, headers={"User-Agent": "Mozilla/5.0",
|
|
37
|
+
"Content-Type": "application/json"})
|
|
38
|
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
39
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
40
|
+
results = []
|
|
41
|
+
for hit in data.get("auto", {}).get("results", []):
|
|
42
|
+
url = hit.get("item_url_path")
|
|
43
|
+
if hit.get("type") != "t" or not url: # tracks only
|
|
44
|
+
continue
|
|
45
|
+
name = hit.get("name", "untitled")
|
|
46
|
+
band = hit.get("band_name", "")
|
|
47
|
+
results.append({"id": url, "url": url,
|
|
48
|
+
"title": f"{band} - {name}" if band else name,
|
|
49
|
+
"extractor": "bandcamp"})
|
|
50
|
+
if len(results) >= limit:
|
|
51
|
+
break
|
|
52
|
+
if not results:
|
|
53
|
+
raise RuntimeError("no results on Bandcamp")
|
|
54
|
+
return results
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def mixcloud_search(query, limit=10):
|
|
58
|
+
"""Mixcloud public search API; returns uploads yt-dlp can download."""
|
|
59
|
+
data = _json_url(
|
|
60
|
+
"https://api.mixcloud.com/search/?q=" + quote(query)
|
|
61
|
+
+ "&type=cloudcast&limit=" + str(limit))
|
|
62
|
+
return [{"id": item.get("key", ""), "url": item.get("url"),
|
|
63
|
+
"title": item.get("name", "untitled"),
|
|
64
|
+
"extractor": "mixcloud"}
|
|
65
|
+
for item in data.get("data", [])[:limit] if item.get("url")]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Every source the Search tab queries: the two yt-dlp search prefixes plus
|
|
69
|
+
# the two public-endpoint searches (still downloaded through yt-dlp).
|
|
70
|
+
SEARCH_SOURCES = (
|
|
71
|
+
("YouTube", lambda q, n: search(q, n, "ytsearch")),
|
|
72
|
+
("SoundCloud", lambda q, n: search(q, n, "scsearch")),
|
|
73
|
+
("Bandcamp", bandcamp_search),
|
|
74
|
+
("Mixcloud", mixcloud_search),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# display order of the grouped Search tab
|
|
78
|
+
SOURCE_ORDER = [name for name, _fn in SEARCH_SOURCES]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def search_all(query, limit=10):
|
|
82
|
+
"""The single shared multi-source search, used by both the CLI and the
|
|
83
|
+
TUI. Runs every source in SEARCH_SOURCES and returns one tuple per
|
|
84
|
+
source: (name, entries, error) — error is None on success so callers
|
|
85
|
+
can show exactly why a source failed."""
|
|
86
|
+
out = []
|
|
87
|
+
for name, fn in SEARCH_SOURCES:
|
|
88
|
+
try:
|
|
89
|
+
out.append((name, fn(query, limit), None))
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
out.append((name, [], exc))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def download(entry):
|
|
96
|
+
"""Save the entry's best audio stream in the library, without re-encoding. Returns (path, outcome)."""
|
|
97
|
+
url = (entry.get("webpage_url") or entry.get("url")
|
|
98
|
+
or f"https://www.youtube.com/watch?v={entry['id']}")
|
|
99
|
+
opts = {
|
|
100
|
+
"quiet": True,
|
|
101
|
+
"noprogress": True,
|
|
102
|
+
"noplaylist": True,
|
|
103
|
+
"format": "bestaudio/best",
|
|
104
|
+
"outtmpl": str(LIBRARY / "%(title)s.%(ext)s"),
|
|
105
|
+
"ffmpeg_location": imageio_ffmpeg.get_ffmpeg_exe(), # yt-dlp needs it to fix up some containers
|
|
106
|
+
}
|
|
107
|
+
LIBRARY.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
with YoutubeDL(opts) as ydl:
|
|
109
|
+
info = ydl.extract_info(url, download=False)
|
|
110
|
+
target = Path(ydl.prepare_filename(info))
|
|
111
|
+
if target.exists():
|
|
112
|
+
return target, "already in library"
|
|
113
|
+
ydl.download([url])
|
|
114
|
+
return target, "saved"
|