Compare commits

..
12 Commits
Author SHA1 Message Date
jhkaktus c44e861b16 add exackt type on app.rs of the gui 2026-09-13 15:14:41 +02:00
jhkaktus 0f406c02ce add PKGBUILD 2026-09-13 15:08:45 +02:00
jhkaktus 769cec8dbe add play-all 2026-07-13 17:27:44 +02:00
jhkaktus 63287fcc8a add auth system 2026-07-12 18:10:17 +02:00
jhkaktus 93be6a7f68 Rename LICENSE.md to LICENSE 2026-07-08 20:01:02 +02:00
jhkaktus 351bdb13f8 rename licence 2026-07-02 21:11:52 +02:00
jhkaktus 0b10095ebf add infos for reporting bugs 2026-07-02 21:04:39 +02:00
jhkaktus e61c3456be actualise readme 2026-07-02 21:01:16 +02:00
jhkaktus 7836add8bd add readme 2026-07-02 20:59:00 +02:00
jhkaktus e222d48179 make better fuzzy search 2026-07-01 20:32:32 +02:00
jhkaktus 1c75494c94 Update LICENSE 2026-07-01 19:41:12 +02:00
jhkaktus 0c2638fec3 fix init angain again again (hopefully last time) 2026-06-30 17:36:39 +02:00
22 changed files with 2516 additions and 158 deletions
+12
View File
@@ -0,0 +1,12 @@
## Reporting Bugs
Before opening an issue, please check whether the problem has already been reported.
Include:
- Operating system
- SJNmusic version
- Steps to reproduce
- Expected behavior
- Actual behavior
- Logs (if available)
+14 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 GhostRAM OS Contributors Copyright (c) 2025 SJNmusic Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -12,6 +12,19 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
This software may include functionality that interacts with third-party
services (including but not limited to media platforms such as YouTube).
The Software itself does not provide any media content. Any content accessed,
downloaded, or processed using this Software is the sole responsibility of
the user.
Users are responsible for ensuring that their use of the Software complies
with applicable laws, regulations, and the terms of service of any third-party
platforms they interact with.
The authors and contributors do not endorse or encourage the unauthorized
download or distribution of copyrighted material.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+98
View File
@@ -0,0 +1,98 @@
# SJNmusic
SJNmusic is a self-hosted music library and playback system with a local daemon, command-line interface, and graphical user interface.
It is designed to provide a fast and lightweight way to manage a personal music collection, create playlists, and control playback from multiple clients.
## Features
* Local music library
* Fast fuzzy search
* Playlist management
* Playback queue
* HTTP daemon (`sjnmusicd`)
* Command-line client (`sjnmusic`)
* Graphical user interface
* Asynchronous downloads
* Local metadata database
* Shuffle and repeat modes
* Playback history and statistics
* JSON output for scripting and automation
## Components
| Component | Description |
| ----------- | ---------------------------------------------------------------- |
| `sjnmusicd` | Background daemon responsible for the music library and playback |
| `sjnmusic` | Command-line client communicating with the daemon |
| GUI | Graphical interface for everyday use |
## Installation
Currently there are no official packages.
Clone the repository and build using Cargo:
```bash
git clone <repository-url>
cd SJNmusic
cargo build --release
```
## Basic Usage
Start the daemon:
```bash
target/release/sjnmusicd
```
Show all songs:
```bash
cli/sjnmusic songs
```
Play a song:
```bash
cli/sjnmusic play "song from my libary"
```
Create a playlist:
```bash
cli/sjnmusic pl-new "favourites"
```
Show playback status:
```bash
cli/sjnmusic status
```
## Download Support
SJNmusic can optionally use external tools such as `yt-dlp` to import media into the local music library.
Downloaded media becomes part of the user's local library and can then be searched, organized, and played like any other local file.
SJNmusic itself does **not** provide, host, distribute, or include any copyrighted media.
## Legal Notice
Users are solely responsible for ensuring that any media they access or download complies with applicable copyright laws and the terms of service of the platforms they use.
SJNmusic is a general-purpose music management application. It is intended for managing media that the user is legally permitted to access and store.
## Project Status
SJNmusic is currently under active development.
Interfaces, commands, APIs, and file formats may change between releases.
Bug reports, feature requests, and pull requests are welcome.
## License
This project is licensed under the MIT License.
+25
View File
@@ -0,0 +1,25 @@
pkgname=sjnmusic-cli-git
pkgver=r11.f67e43c
pkgrel=1
pkgdesc="CLI client for sjnmusicd HTTP daemon"
arch=('any')
url="https://github.com/serverjonas/SJNmusic"
license=('MIT')
depends=('python' 'sjnmusicd-git')
makedepends=('git')
provides=('sjnmusic-cli')
conflicts=('sjnmusic-cli')
source=("git+https://github.com/serverjonas/SJNmusic.git")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/SJNmusic"
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
package() {
cd "$srcdir/SJNmusic"
install -Dm755 "cli/sjnmusic" "$pkgdir/usr/bin/sjnmusic"
}
Binary file not shown.
Binary file not shown.
+350 -115
View File
@@ -29,6 +29,16 @@ def build_parser() -> argparse.ArgumentParser:
type=int, type=int,
default=int(os.environ.get("SJNMUSIC_PORT", str(DEFAULT_PORT))), default=int(os.environ.get("SJNMUSIC_PORT", str(DEFAULT_PORT))),
) )
p.add_argument(
"--token",
default=os.environ.get("SJNMUSIC_TOKEN"),
help=(
"bearer token for the daemon (Authorization: Bearer <token>). "
"Required when talking to a non-loopback daemon. Can also be set "
"via the SJNMUSIC_TOKEN environment variable. On loopback the "
"CLI auto-discovers the token from GET /auth/token."
),
)
p.add_argument("--json", action="store_true", help="print raw JSON responses") p.add_argument("--json", action="store_true", help="print raw JSON responses")
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
@@ -74,6 +84,10 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("resume", help="resume playback") sub.add_parser("resume", help="resume playback")
sub.add_parser("shuffle", help="shuffle the queue in place") sub.add_parser("shuffle", help="shuffle the queue in place")
sub.add_parser("clear", help="empty the queue") sub.add_parser("clear", help="empty the queue")
sub.add_parser(
"play-all",
help="shuffle the entire library into the queue and start playing",
)
sub.add_parser("songs", help="list all known songs") sub.add_parser("songs", help="list all known songs")
sub.add_parser("playlists", help="list all playlists") sub.add_parser("playlists", help="list all playlists")
sub.add_parser("downloads", help="list recent yt-dlp download jobs") sub.add_parser("downloads", help="list recent yt-dlp download jobs")
@@ -139,21 +153,21 @@ def join_query(args) -> str:
return " ".join(parts).strip() return " ".join(parts).strip()
def call(host, port, method, route, body=None, query=None): def call(host, port, method, route, body=None, query=None, timeout=15, headers=None):
qs = "" qs = ""
if query: if query:
qs = "?" + urllib.parse.urlencode(query) qs = "?" + urllib.parse.urlencode(query)
url = f"http://{host}:{port}{route}{qs}" url = f"http://{host}:{port}{route}{qs}"
data = None data = None
headers = {} hdrs = dict(headers or {})
if body is not None: if body is not None:
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json" hdrs.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url=url, data=data, headers=headers, method=method) req = urllib.request.Request(url=url, data=data, headers=hdrs, method=method)
try: try:
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = resp.read().decode("utf-8") payload = resp.read().decode("utf-8")
return resp.status, payload return resp.status, payload
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
@@ -163,6 +177,107 @@ def call(host, port, method, route, body=None, query=None):
sys.exit(2) sys.exit(2)
# Loopback addresses that skip the bearer-token check on the daemon
# side. Kept in sync with the daemon's `auth::is_local_request` so the
# CLI auto-fetch fallback only ever fires when the daemon would
# actually let us through unauthenticated.
LOCAL_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def is_local(host):
return host in LOCAL_HOSTS
def fetch_token(host, port, timeout=5):
"""
Best-effort localhost-only token probe.
GET /auth/token is itself loopback-only on the daemon side, so
reaching it implies we're a process running on the same host. We
do NOT retry on failure — a 403 here means a remote daemon is
pretending to be local and refusing (we should propagate the
error), a 404 means the daemon has auth disabled (don't auto-fetch
any token), and any other non-200 is opaque. We surface the
decoded JSON `token` field on success and `None` on every other
outcome.
"""
try:
status, payload = call(
host, port, "GET", "/auth/token", timeout=timeout
)
except SystemExit:
# `call` exits the process on URLError; treat that as
# "couldn't reach daemon, no token". We re-raise because the
# caller is already going to error out on the original
# request anyway, but only after a transparent re-raise so
# surfacing happens at the right stack frame.
return None
if status != 200:
return None
obj = try_parse(payload)
if not isinstance(obj, dict):
return None
tok = obj.get("token")
if isinstance(tok, str) and tok:
return tok
return None
def request(args, host, port, method, route, body=None, query=None, timeout=15):
"""
Auth-aware wrapper around `call`. Order of operations:
1. If the operator supplied `--token` (or `SJNMUSIC_TOKEN`), attach
`Authorization: Bearer <token>` and send straight to the
daemon. We never auto-fetch on top of an explicit token — that
would silently swap a user-supplied secret with the daemon's
current one (which is rarely what an operator wants).
2. Otherwise: if the peer is loopback, send without a token and
then, if the daemon rejected with 401, do a one-shot
`GET /auth/token` and retry. This keeps today's CLI workflows
unchanged: a user with auto-fetch works even though the
daemon now requires tokens for non-loopback callers. We
decline to silently fetch on a non-loopback host because that
would defeat the whole point of forcing operators to give an
explicit token to remote daemons.
3. If the peer is non-loopback and no token was supplied, the
call proceeds without a header so the operator gets a
daemon-issued 401 instead of a CLI-level error — easier to
debug in the daemon logs.
"""
explicit_token = getattr(args, "token", None) or ""
hdrs = {}
if explicit_token:
hdrs["Authorization"] = f"Bearer {explicit_token}"
status, payload = call(
host, port, method, route, body=body, query=query,
timeout=timeout, headers=hdrs,
)
# Auto-fetch only on FIRST observation of 401 on a loopback peer
# when no explicit token was supplied AND this is not already the
# token endpoint itself (which would obviously never require a
# token). The 401 must have come from a request method that the
# server would normally respond 2xx to without auth; if /auth/token
# is what we just hit, we'd loop forever.
if (
status == 401
and not explicit_token
and is_local(host)
and route != "/auth/token"
):
fetched = fetch_token(host, port)
if fetched:
hdrs["Authorization"] = f"Bearer {fetched}"
status, payload = call(
host, port, method, route, body=body, query=query,
timeout=timeout, headers=hdrs,
)
return status, payload
def fmt_secs(s): def fmt_secs(s):
if s is None: if s is None:
return "--:--" return "--:--"
@@ -183,6 +298,176 @@ def try_parse(payload):
return None return None
def print_candidate(idx, c):
"""Pretty-print one ranked candidate row for the picker."""
score = c.get("score")
score_str = f"score={score:+d}" if score is not None else ""
flags = " ".join(c.get("flags") or [])
flag_str = f" [{flags}]" if flags else ""
print(
f"[{idx}] {c.get('title', '?')} -- "
f"{c.get('uploader') or '(unknown)':24} "
f"({fmt_secs(c.get('duration_secs')):>5}) "
f"{score_str}{flag_str}"
)
def interactive_pick(candidates):
"""Show the picker; return the user's choice (or candidates[0])."""
for i, c in enumerate(candidates, 1):
print_candidate(i, c)
try:
raw = input("Pick a song [1]: ").strip()
except EOFError:
print(
"error: no input available; re-run with --auto or supply a piped number",
file=sys.stderr,
)
sys.exit(2)
if raw == "":
return candidates[0]
try:
idx = int(raw) - 1
except ValueError:
print(f"error: not a number: {raw!r}", file=sys.stderr)
sys.exit(2)
if idx < 0 or idx >= len(candidates):
print(
f"error: choice out of range (1-{len(candidates)})",
file=sys.stderr,
)
sys.exit(2)
return candidates[idx]
def legacy_pick(results, force_auto):
"""Legacy /search/yt picker (no scoring fields)."""
if force_auto:
return results[0]
return interactive_pick(results)
def do_init(args, host, port, q, force_pick, force_auto, limit):
"""
Drive the `init` subcommand through the new ranked pipeline.
Default behaviour: `GET /pick?q=…` and let the daemon either
auto-select (when the top score beats the runner-up by margin) or
surface ranked candidates for the picker. Falls back to the legacy
`/search/yt` picker if the daemon doesn't speak `/pick` (older
builds), so the CLI stays forward/backward compatible.
Returns the chosen candidate dict (with at least a 'url' key) so
`main()` can dispatch the actual `/init` POST uniformly.
"""
pick_query = {"q": q}
if limit is not None and limit > 0:
pick_query["limit"] = str(limit)
# 3 parallel yt-dlp fetches can easily take 30+ seconds each
# on a slow network; the daemon drops its state mutex before
# invoking them, but the network round-trips themselves are
# unchanged. Generous timeout to avoid ghost-failing.
pick_status, pick_payload = request(
args, host, port, "GET", "/pick", query=pick_query, timeout=180
)
pick_obj = try_parse(pick_payload)
if pick_status != 200 or pick_obj is None or "kind" not in pick_obj:
# Fall back to legacy `/search/yt` if the daemon is too old
# to know about `/pick`. Keeps the CLI working across daemon
# upgrades in either direction.
legacy_status, legacy_payload = request(
args, host, port, "GET", "/search/yt",
query=pick_query, timeout=120,
)
legacy_obj = try_parse(legacy_payload) or {}
legacy_results = legacy_obj.get("results") or []
if legacy_status != 200:
return (legacy_status, legacy_payload)
if not legacy_results:
print(
f"error: no yt-dlp results for {q!r}",
file=sys.stderr,
)
sys.exit(1)
return (
200,
json.dumps({"chosen": legacy_pick(legacy_results, force_auto)}),
)
kind = pick_obj["kind"]
if kind == "empty":
print(
f"error: no yt-dlp results for {q!r}: "
f"{pick_obj.get('message', '')}",
file=sys.stderr,
)
sys.exit(1)
if kind == "auto":
# Daemon is confident — surface the choice so the user sees
# what was picked, then download.
score = pick_obj.get("score", 0)
print(
f"[auto-pick score={score}] "
f"{pick_obj.get('title', '?')} -- "
f"{pick_obj.get('uploader', '(unknown)')} "
f"({fmt_secs(pick_obj.get('duration_secs'))})"
)
if force_pick:
# `--pick` overrides the auto decision: ask the user.
ranked_status, ranked_payload = request(
args, host, port, "GET", "/search/yt/ranked",
query=pick_query, timeout=180,
)
ranked_obj = try_parse(ranked_payload) or {}
ranked_results = ranked_obj.get("results") or []
if ranked_status != 200 or not ranked_results:
print_response_args(args, ranked_status, ranked_payload)
sys.exit(1)
return (
200,
json.dumps({"chosen": interactive_pick(ranked_results)}),
)
return (
200,
json.dumps({
"chosen": {
"url": pick_obj.get("url"),
"title": pick_obj.get("title"),
"uploader": pick_obj.get("uploader"),
"score": score,
"duration_secs": pick_obj.get("duration_secs"),
"flags": pick_obj.get("flags", []),
},
}),
)
if kind == "needs_choice":
candidates = pick_obj.get("candidates") or []
if not candidates:
print(
f"error: no yt-dlp results for {q!r}",
file=sys.stderr,
)
sys.exit(1)
if force_auto:
chosen = candidates[0]
print(
f"[auto-pick score={chosen.get('score', 0)}] "
f"{chosen.get('title', '?')} -- "
f"{chosen.get('uploader', '(unknown)')} "
f"({fmt_secs(chosen.get('duration_secs'))})"
)
else:
chosen = interactive_pick(candidates)
return (200, json.dumps({"chosen": chosen}))
print(f"error: unknown /pick kind {kind!r}", file=sys.stderr)
sys.exit(1)
def print_response_args(args, status, payload):
"""Helper for subroutes that already pre-formatted a payload."""
print_response(args, status, payload)
def print_response(args, status, payload): def print_response(args, status, payload):
if args.json: if args.json:
print(f"HTTP {status}\n{payload}") print(f"HTTP {status}\n{payload}")
@@ -256,6 +541,9 @@ def print_response(args, status, payload):
else: else:
print("now playing: (idle)") print("now playing: (idle)")
return return
if isinstance(obj, dict) and "queued" in obj:
print(f"queued {obj['queued']} song(s) in random order")
return
if isinstance(obj, dict) and "songs" in obj: if isinstance(obj, dict) and "songs" in obj:
for s in obj["songs"]: for s in obj["songs"]:
print(f"[{s['id']}] {s['name']}") print(f"[{s['id']}] {s['name']}")
@@ -301,181 +589,128 @@ def main(argv=None):
return f"/playlists/{urllib.parse.quote(name)}" return f"/playlists/{urllib.parse.quote(name)}"
if args.cmd == "play": if args.cmd == "play":
status, payload = call(host, port, "POST", "/play", body=with_q(None)) status, payload = request(args, host, port, "POST", "/play", body=with_q(None))
elif args.cmd == "add": elif args.cmd == "add":
status, payload = call(host, port, "POST", "/add", body=with_q(None)) status, payload = request(args, host, port, "POST", "/add", body=with_q(None))
elif args.cmd == "del": elif args.cmd == "del":
if not q: if not q:
print("error: 'del' requires a search query", file=sys.stderr) print("error: 'del' requires a search query", file=sys.stderr)
sys.exit(2) sys.exit(2)
status, payload = call(host, port, "POST", "/del", body=with_q(None)) status, payload = request(args, host, port, "POST", "/del", body=with_q(None))
elif args.cmd == "init": elif args.cmd == "init":
if not q: if not q:
print("error: 'init' requires a song name", file=sys.stderr) print("error: 'init' requires a song name", file=sys.stderr)
sys.exit(2) sys.exit(2)
force_pick = getattr(args, "pick", False) force_pick = getattr(args, "pick", False)
force_auto = getattr(args, "auto", False) force_auto = getattr(args, "auto", False)
# `stdin_isatty` controls whether we *auto*-select (non-interactive) limit = getattr(args, "limit", None)
# versus *prompt* the user. `--pick` overrides a non-TTY stdin so
# scripts can still drive the picker via a piped index if they want. # Drive the ranked pipeline (do_init returns the chosen
interactive = force_pick or ( # candidate as a JSON envelope so we don't need separate
sys.stdin.isatty() and not force_auto # branches below for `/init`). Routing through `request`
# means auto-fetch-from-/auth/token also works for the
# search (/pick) and the eventual /init POST.
_, envelope_payload = do_init(
args, host, port, q, force_pick, force_auto, limit,
)
chosen_obj = try_parse(envelope_payload) or {}
chosen = chosen_obj.get("chosen") or {}
status, payload = request(
args, host, port, "POST", "/init",
body={"name": q, "url": chosen.get("url") or ""},
) )
if not interactive:
# Legacy / scripted path: top result only, no round-trip for
# the candidate list. The daemon falls back to ytsearch1:NAME
# when no URL is supplied.
status, payload = call(
host, port, "POST", "/init", body={"song": q}
)
else:
limit = getattr(args, "limit", None)
query = {"q": q}
if limit is not None and limit > 0:
query["limit"] = str(limit)
status, payload = call(
host, port, "GET", "/search/yt", query=query
)
obj = try_parse(payload)
results = (obj or {}).get("results") or []
if status != 200:
print_response(args, status, payload)
sys.exit(1)
if not results:
print(
f"error: no yt-dlp results for {q!r}",
file=sys.stderr,
)
sys.exit(1)
for i, r in enumerate(results, 1):
uploader = r.get("uploader") or "(unknown)"
print(
f"[{i}] {r.get('title', '?')} -- {uploader} "
f"({fmt_secs(r.get('duration_secs'))})"
)
try:
raw = input("Pick a song [1]: ").strip()
except EOFError:
print(
"error: no input available; re-run with --auto or supply a piped number",
file=sys.stderr,
)
sys.exit(2)
if raw == "":
chosen_idx = 0
else:
try:
chosen_idx = int(raw) - 1
except ValueError:
print(
f"error: not a number: {raw!r}",
file=sys.stderr,
)
sys.exit(2)
if chosen_idx < 0 or chosen_idx >= len(results):
print(
f"error: choice out of range (1-{len(results)})",
file=sys.stderr,
)
sys.exit(2)
chosen = results[chosen_idx]
status, payload = call(
host,
port,
"POST",
"/init",
body={"name": q, "url": chosen.get("url") or ""},
)
elif args.cmd == "init-batch": elif args.cmd == "init-batch":
status, payload = call( status, payload = request(
host, port, "POST", "/init/batch", body={"songs": args.queries} args, host, port, "POST", "/init/batch", body={"songs": args.queries}
) )
elif args.cmd == "search": elif args.cmd == "search":
if not q: if not q:
print("error: 'search' requires a query", file=sys.stderr) print("error: 'search' requires a query", file=sys.stderr)
sys.exit(2) sys.exit(2)
status, payload = call(host, port, "GET", "/search", query={"q": q}) status, payload = request(args, host, port, "GET", "/search", query={"q": q})
elif args.cmd == "search-all": elif args.cmd == "search-all":
if not q: if not q:
print("error: 'search-all' requires a query", file=sys.stderr) print("error: 'search-all' requires a query", file=sys.stderr)
sys.exit(2) sys.exit(2)
status, payload = call(host, port, "GET", "/search/all", query={"q": q}) status, payload = request(args, host, port, "GET", "/search/all", query={"q": q})
elif args.cmd == "queue": elif args.cmd == "queue":
status, payload = call(host, port, "GET", "/queue") status, payload = request(args, host, port, "GET", "/queue")
elif args.cmd in ("now", "status"): elif args.cmd in ("now", "status"):
status, payload = call(host, port, "GET", "/now-playing") status, payload = request(args, host, port, "GET", "/now-playing")
elif args.cmd == "skip": elif args.cmd == "skip":
status, payload = call(host, port, "POST", "/skip") status, payload = request(args, host, port, "POST", "/skip")
elif args.cmd == "pause": elif args.cmd == "pause":
status, payload = call(host, port, "POST", "/pause") status, payload = request(args, host, port, "POST", "/pause")
elif args.cmd == "resume": elif args.cmd == "resume":
status, payload = call(host, port, "POST", "/resume") status, payload = request(args, host, port, "POST", "/resume")
elif args.cmd == "seek": elif args.cmd == "seek":
status, payload = call(host, port, "POST", "/seek", body={"secs": args.secs}) status, payload = request(args, host, port, "POST", "/seek", body={"secs": args.secs})
elif args.cmd == "shuffle": elif args.cmd == "shuffle":
status, payload = call(host, port, "POST", "/queue/shuffle") status, payload = request(args, host, port, "POST", "/queue/shuffle")
elif args.cmd == "clear": elif args.cmd == "clear":
status, payload = call(host, port, "POST", "/queue/clear") status, payload = request(args, host, port, "POST", "/queue/clear")
elif args.cmd == "play-all":
status, payload = request(args, host, port, "POST", "/play/all", body={})
elif args.cmd == "songs": elif args.cmd == "songs":
status, payload = call(host, port, "GET", "/songs") status, payload = request(args, host, port, "GET", "/songs")
elif args.cmd == "downloads": elif args.cmd == "downloads":
status, payload = call(host, port, "GET", "/downloads") status, payload = request(args, host, port, "GET", "/downloads")
elif args.cmd == "history": elif args.cmd == "history":
status, payload = call(host, port, "GET", "/history") status, payload = request(args, host, port, "GET", "/history")
elif args.cmd == "stats": elif args.cmd == "stats":
status, payload = call(host, port, "GET", "/stats") status, payload = request(args, host, port, "GET", "/stats")
elif args.cmd == "volume": elif args.cmd == "volume":
if args.value is None: if args.value is None:
status, payload = call(host, port, "GET", "/volume") status, payload = request(args, host, port, "GET", "/volume")
else: else:
# Treat 0..100 as a percentage of rodio's default 1.0. # Treat 0..100 as a percentage of rodio's default 1.0.
v = args.value v = args.value
if v > 2.0: if v > 2.0:
v = v / 100.0 v = v / 100.0
status, payload = call(host, port, "POST", "/volume", body={"vol": v}) status, payload = request(args, host, port, "POST", "/volume", body={"vol": v})
elif args.cmd == "repeat": elif args.cmd == "repeat":
if args.mode is None: if args.mode is None:
status, payload = call(host, port, "GET", "/repeat") status, payload = request(args, host, port, "GET", "/repeat")
else: else:
status, payload = call(host, port, "POST", "/repeat", body={"mode": args.mode}) status, payload = request(args, host, port, "POST", "/repeat", body={"mode": args.mode})
elif args.cmd == "playlists": elif args.cmd == "playlists":
status, payload = call(host, port, "GET", "/playlists") status, payload = request(args, host, port, "GET", "/playlists")
elif args.cmd == "help": elif args.cmd == "help":
status, payload = call(host, port, "GET", "/help") status, payload = request(args, host, port, "GET", "/help")
elif args.cmd == "pl-new": elif args.cmd == "pl-new":
status, payload = call(host, port, "POST", "/playlists", body={"name": args.name}) status, payload = request(args, host, port, "POST", "/playlists", body={"name": args.name})
elif args.cmd == "pl-show": elif args.cmd == "pl-show":
status, payload = call(host, port, "GET", pl_route(args.name)) status, payload = request(args, host, port, "GET", pl_route(args.name))
elif args.cmd == "pl-add": elif args.cmd == "pl-add":
if not q: if not q:
print("error: 'pl-add' requires a song query", file=sys.stderr) print("error: 'pl-add' requires a song query", file=sys.stderr)
sys.exit(2) sys.exit(2)
status, payload = call( status, payload = request(
host, port, "POST", f"{pl_route(args.name)}/add", body={"query": q} args, host, port, "POST", f"{pl_route(args.name)}/add", body={"query": q}
) )
elif args.cmd == "pl-play": elif args.cmd == "pl-play":
status, payload = call(host, port, "POST", f"{pl_route(args.name)}/play") status, payload = request(args, host, port, "POST", f"{pl_route(args.name)}/play")
elif args.cmd == "pl-rm": elif args.cmd == "pl-rm":
status, payload = call(host, port, "DELETE", pl_route(args.name)) status, payload = request(args, host, port, "DELETE", pl_route(args.name))
elif args.cmd == "pl-rm-song": elif args.cmd == "pl-rm-song":
status, payload = call( status, payload = request(
host, port, "DELETE", f"{pl_route(args.name)}/songs/{args.song_id}" args, host, port, "DELETE", f"{pl_route(args.name)}/songs/{args.song_id}"
) )
elif args.cmd == "pl-rename": elif args.cmd == "pl-rename":
status, payload = call( status, payload = request(
host, port, "PATCH", pl_route(args.old), body={"name": args.new} args, host, port, "PATCH", pl_route(args.old), body={"name": args.new}
) )
elif args.cmd == "pl-dup": elif args.cmd == "pl-dup":
status, payload = call( status, payload = request(
host, port, "POST", f"{pl_route(args.src)}/duplicate", body={"name": args.dest} args, host, port, "POST", f"{pl_route(args.src)}/duplicate", body={"name": args.dest}
) )
elif args.cmd == "pl-reorder": elif args.cmd == "pl-reorder":
status, payload = call( status, payload = request(
host, port, "POST", f"{pl_route(args.name)}/reorder", args, host, port, "POST", f"{pl_route(args.name)}/reorder",
body={"from": args.from_pos, "to": args.to_pos}, body={"from": args.from_pos, "to": args.to_pos},
) )
else: else:
status, payload = call(host, port, "GET", "/help") status, payload = request(args, host, port, "GET", "/help")
print_response(args, status, payload) print_response(args, status, payload)
+7
View File
@@ -261,6 +261,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@@ -1562,6 +1568,7 @@ dependencies = [
name = "sjnmusicd" name = "sjnmusicd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"dirs", "dirs",
"env_logger", "env_logger",
"log", "log",
+5
View File
@@ -16,6 +16,11 @@ env_logger = "0.11"
toml = "0.8" toml = "0.8"
percent-encoding = "2" percent-encoding = "2"
rand = "0.8" rand = "0.8"
# `base64` is used by the optional bearer-token authentication module
# (`auth.rs`) to encode the 32 random bytes of `auth_token` into a 43-char
# URL-safe string that fits comfortably in an HTTP header. Pull the URL-safe
# alphabet (no padding) so the value needs no further quoting.
base64 = "0.22"
# `zbus` exposes an org.mpris.MediaPlayer2.sjnmusic service so Linux DEs and # `zbus` exposes an org.mpris.MediaPlayer2.sjnmusic service so Linux DEs and
# media-key daemons can control the daemon even when the GUI process is # media-key daemons can control the daemon even when the GUI process is
# closed. We run zbus inside a single-threaded tokio runtime hosted by the # closed. We run zbus inside a single-threaded tokio runtime hosted by the
+38
View File
@@ -0,0 +1,38 @@
pkgname=sjnmusicd-git
pkgver=r8.7d80656
pkgrel=1
pkgdesc="SJNmusic daemon (Rust)"
arch=('x86_64')
url="https://github.com/serverjonas/SJNmusic"
license=('MIT')
depends=('glibc' 'alsa-lib' 'yt-dlp')
makedepends=('git' 'rust' 'cargo')
source=("git+https://github.com/serverjonas/SJNmusic.git")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/SJNmusic"
printf "r%s.%s" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
build() {
cd "$srcdir/SJNmusic/daemon"
cargo build --release
}
package() {
cd "$srcdir/SJNmusic/daemon"
install -Dm755 target/release/sjnmusicd \
"$pkgdir/usr/bin/sjnmusicd"
install -Dm644 systemd/sjnmusicd.service \
"$pkgdir/usr/lib/systemd/user/sjnmusicd.service"
install -Dm644 "$srcdir/SJNmusic/LICENSE" \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+206
View File
@@ -0,0 +1,206 @@
//! Authentication helpers for the optional bearer-token scheme.
//!
//! The daemon can be configured to require `Authorization: Bearer <token>`
//! for any request whose peer is not a loopback address. This module owns
//! the three primitives every other auth site relies on:
//!
//! * [`generate_token`] — 32 cryptographically random bytes encoded as
//! URL-safe unpadded base64 (43 chars, header-safe).
//! * [`constant_time_eq`] — string compare that does not short-circuit on
//! mismatched prefixes/lengths, so a guesser's per-token timing leak is
//! bounded to the constant overhead of the loop.
//! * [`is_local_request`] — true iff the request's TCP peer is in the
//! loopback space (including IPv4-mapped IPv6 like `::ffff:127.0.0.1`,
//! which `IpAddr::is_loopback` alone does not classify as loopback).
//!
//! [`bearer_from_request`] is the only parse path the HTTP layer uses to
//! extract the credential string, and [`request_is_authorized`] packages
//! the full "is this caller allowed?" decision so the dispatcher can ask
//! one boolean question per route.
//!
//! These helpers never log the credential. The HTTP server's rejection
//! paths must call [`request_is_authorized`] (or `bearer_from_request`
//! + [`constant_time_eq`]) and never `debug!` the bearer header itself:
//! leaking the value to disk or stdout would defeat the entire scheme.
use std::net::{IpAddr, SocketAddr};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
use tiny_http::Request;
/// Length of the URL-safe-base64 encoding of 32 bytes (256 / 6 = 43
/// chars, no padding). Mirrored as a constant in the unit tests below.
const TOKEN_LEN: usize = 43;
/// Generate a fresh 32-byte bearer token. Output is 43 chars of URL-safe
/// unpadded base64, drawn from `rand::thread_rng()` which is itself
/// backed by `getrandom` on Linux — adequate for a single shared secret
/// against non-state-level attackers.
pub fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Constant-time equality on string slices. We do NOT short-circuit when
/// lengths differ: the loop runs over `max(a.len(), b.len())` and folds
/// the length mismatch into the same XOR-accumulator. For tokens the
/// legitimate length is a 43-char public constant, so even the
/// short-circuit-on-length approach wouldn't give an attacker useful
/// information — but defending against the corner case costs almost
/// nothing here and keeps the timing profile uniform.
///
/// The `core::hint::black_box` calls and a final `Ordering::SeqCst`
/// compiler fence keep LLVM from recognising the "compute a single
/// boolean, all of whose inputs feed through `|=`" idiom and
/// optimising it down to a short-circuiting `==` in release builds.
/// Defense in depth against an optimisation that would re-introduce
/// the very timing channel this function exists to suppress.
pub fn constant_time_eq(a: &str, b: &str) -> bool {
use std::sync::atomic::{compiler_fence, Ordering};
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let n = a_bytes.len().max(b_bytes.len());
let mut diff: u8 = (a_bytes.len() != b_bytes.len()) as u8;
for i in 0..n {
let x = a_bytes.get(i).copied().unwrap_or(0);
let y = b_bytes.get(i).copied().unwrap_or(0);
diff |= x ^ y;
}
let diff = black_box_u8(diff);
compiler_fence(Ordering::SeqCst);
diff == 0
}
#[inline]
fn black_box_u8(x: u8) -> u8 {
// `black_box` is a hint to the optimiser: the value may be observed
// by an external observer and so should not be elided or folded.
std::hint::black_box(x)
}
/// True iff `req`'s peer address is the IPv4 loopback subnet (including
/// `127.0.0.0/8` in principle, though tiny_http's `remote_addr` always
/// reports `127.0.0.1` on Linux), `::1`, OR an IPv4-mapped IPv6 address
/// like `::ffff:127.0.0.1` that the OS produces when a daemon with a
/// dual-stack socket accepts a v4 client.
pub fn is_local_request(req: &Request) -> bool {
match req.remote_addr() {
Some(addr) => peer_is_loopback(*addr),
None => false,
}
}
fn peer_is_loopback(addr: SocketAddr) -> bool {
match addr.ip() {
IpAddr::V4(v4) => v4.is_loopback(),
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.to_ipv4_mapped()
.map(|v4| v4.is_loopback())
.unwrap_or(false)
}
}
}
/// Extract the bearer token from an `Authorization` header. Returns
/// `None` if the header is absent, falls outside the Bearer scheme
/// (e.g. Basic / Digest), or carries an empty token. Both the header
/// name and the scheme word are matched case-insensitively per RFC 7235.
pub fn bearer_from_request(req: &Request) -> Option<String> {
for header in req.headers() {
if !header.field.equiv("Authorization") {
continue;
}
let trimmed = header.value.as_str().trim_start();
const PREFIX: &str = "Bearer ";
if trimmed.len() >= PREFIX.len()
&& trimmed[..PREFIX.len()].eq_ignore_ascii_case(PREFIX)
{
let token = trimmed[PREFIX.len()..].trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
// Authorization header was present but malformed (wrong scheme,
// empty token, etc). Do NOT silently fall through to treat it
// as "no credential"; the caller's auth check will return 401.
return None;
}
None
}
/// One-line auth decision used by the HTTP dispatcher for every non-auth
/// route. True if the request is from a loopback peer (always trusted),
/// OR if the daemon has authentication disabled, OR if the request
/// carries a `Bearer` header whose value matches the configured token
/// under [`constant_time_eq`].
pub fn request_is_authorized(req: &Request, auth_token: Option<&str>) -> bool {
if is_local_request(req) {
return true;
}
let Some(expected) = auth_token else {
// No token configured → authentication is off entirely.
return true;
};
bearer_from_request(req)
.map(|t| constant_time_eq(&t, expected))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_matches_equal_strings() {
assert!(constant_time_eq("", ""));
assert!(constant_time_eq("a", "a"));
assert!(constant_time_eq("hello world", "hello world"));
}
#[test]
fn constant_time_rejects_unequal_strings() {
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "ab"));
assert!(!constant_time_eq("ab", "abc"));
assert!(!constant_time_eq("", "x"));
assert!(!constant_time_eq("x", ""));
}
#[test]
fn generate_produces_distinct_well_formed_tokens() {
let t1 = generate_token();
let t2 = generate_token();
assert_eq!(t1.len(), TOKEN_LEN);
assert_eq!(t2.len(), TOKEN_LEN);
assert_ne!(t1, t2, "32 random bytes should never collide");
// URL-safe base64 alphabet: A-Z a-z 0-9 - _.
for c in t1.chars() {
assert!(
c.is_ascii_alphanumeric() || c == '-' || c == '_',
"token contained non-url-safe char: {c:?}"
);
}
}
#[test]
fn peer_loopback_accepts_v4_and_v6_variants() {
let v4_loopback: SocketAddr = "127.0.0.1:1234".parse().unwrap();
let v4_other: SocketAddr = "10.0.0.1:1234".parse().unwrap();
let v6_loopback: SocketAddr = "[::1]:1234".parse().unwrap();
let v6_other: SocketAddr = "[2001:db8::1]:1234".parse().unwrap();
let v4_mapped_loopback: SocketAddr =
std::net::SocketAddrV6::new("::ffff:127.0.0.1".parse().unwrap(), 1234, 0, 0)
.into();
assert!(peer_is_loopback(v4_loopback));
assert!(!peer_is_loopback(v4_other));
assert!(peer_is_loopback(v6_loopback));
assert!(!peer_is_loopback(v6_other));
assert!(
peer_is_loopback(v4_mapped_loopback),
"IPv4-mapped IPv6 must classify as loopback"
);
}
}
+27
View File
@@ -116,9 +116,36 @@ pub struct Config {
pub search: SearchConfig, pub search: SearchConfig,
#[serde(default)] #[serde(default)]
pub download: DownloadConfig, pub download: DownloadConfig,
/// When `true`, the daemon requires `Authorization: Bearer <token>`
/// for any request whose peer is NOT a loopback address. The matching
/// `auth_token` is auto-generated at startup if it is still empty
/// (see `daemon::run`), then persisted to disk via
/// [`Config::save`] so subsequent restarts reuse the same token.
#[serde(default)]
pub auth_enabled: bool,
/// Bearer credential compared against incoming `Authorization` headers
/// when `auth_enabled = true`. Empty string disables auth entirely
/// regardless of the `auth_enabled` flag — the daemon treats any
/// `auth_token == ""` as "no token configured, allow all remote".
#[serde(default)]
pub auth_token: String,
} }
impl Config { impl Config {
/// Persist the current `Config` back to its TOML file. Used by the
/// daemon only after a new bearer token has been auto-generated at
/// startup, so the same token is reusable across restarts. Falls
/// back silently on serialization/write failures — the daemon will
/// still boot with the in-memory token, it just won't survive a
/// restart (and will log a warning about that).
pub fn save(&self) -> std::io::Result<()> {
let path = config_path();
let serialized = toml::to_string_pretty(self).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
})?;
fs::write(&path, serialized)
}
/// Load configuration from the TOML file. If the file does not exist, create /// Load configuration from the TOML file. If the file does not exist, create
/// it with sensible defaults so the daemon can be configured on first run. /// it with sensible defaults so the daemon can be configured on first run.
pub fn load() -> Self { pub fn load() -> Self {
+89 -2
View File
@@ -5,6 +5,7 @@ use log::{debug, error, info, warn};
use crate::{ use crate::{
audio::spawn_audio_thread, audio::spawn_audio_thread,
auth,
config::Config, config::Config,
db::open_db, db::open_db,
mpris, mpris,
@@ -12,9 +13,95 @@ use crate::{
state::{DaemonState, RepeatMode, Song}, state::{DaemonState, RepeatMode, Song},
}; };
pub fn run(cfg: Config) { /// Returns `true` when the bind host is a loopback interface
/// (`127.0.0.1`, `::1`, or the conventional `localhost` string). Used by
/// the startup warning paths to decide whether to alert the operator
/// about exposing the daemon on a network-reachable address.
fn bind_is_loopback(host: &str) -> bool {
matches!(host, "127.0.0.1" | "::1" | "localhost")
}
fn warn_if_publicly_exposed(host: &str, auth_on: bool) {
if bind_is_loopback(host) {
return;
}
if auth_on {
warn!(
"sjnmusicd is listening on {host}; remote peers require the configured bearer token (Authorization: Bearer <token>)"
);
} else {
warn!(
"sjnmusicd is listening on {host} WITHOUT authentication; anyone on the network can control playback"
);
// Mirror the warning to stderr so the operator sees it even
// when they start the daemon interactively without a log file
// and without RUST_LOG configured. env_logger's default filter
// ("info") would already include the warn! above, but historical
// service-launch recipes often run sjnmusicd via a unit-file
// and pipe stdout to /dev/null — belt and braces for safety.
eprintln!(
"WARNING: sjnmusicd is listening on {host} WITHOUT authentication. \
Anyone on the network can control playback. \
Set auth_enabled = true in {} (alongside any auth_token you want to use) to require bearer tokens.",
crate::paths::config_path(),
);
}
}
/// Build the effective bearer-token the HTTP server should use for
/// the lifetime of this process. Behaviour matches the design spec:
///
/// * `auth_enabled = false` ⇒ authentication is off entirely.
/// Token returned is `None`, regardless of any leftover config value.
/// * `auth_enabled = true && auth_token == ""` ⇒ generate a fresh
/// 32-byte URL-safe base64 token, persist it to disk so the next
/// startup keeps using the same one, and return it.
/// * `auth_enabled = true && auth_token != ""` ⇒ reuse what the
/// operator already configured. We do NOT validate the token's
/// content (e.g. base64-ness) at boot — if it's malformed,
/// `request_is_authorized` will simply never match and any remote
/// caller will see 401. That's safe: a wrong token != a correct
/// token to a constant-time comparator.
///
/// Returns `Some(token)` when auth is on, `None` otherwise. The
/// caller decides whether to emit startup warnings based on whether
/// the daemon is exposed on a non-loopback address.
fn resolve_auth_token(cfg: &mut Config) -> Option<String> {
if !cfg.auth_enabled {
return None;
}
if !cfg.auth_token.is_empty() {
info!(
"Authentication enabled; using configured auth_token (length {})",
cfg.auth_token.len()
);
return Some(cfg.auth_token.clone());
}
let token = auth::generate_token();
info!(
"Authentication enabled; generated new auth_token (length {}). Use `sjnmusic --host 127.0.0.1 GET /auth/token` to read it.",
token.len()
);
cfg.auth_token = token.clone();
match cfg.save() {
Ok(()) => info!(
"Persisted new auth_token to {} so restarts keep using the same token.",
crate::paths::config_path()
),
Err(e) => warn!(
"Failed to persist new auth_token to {}: {e}. Token will rotate on every restart until the file becomes writable.",
crate::paths::config_path()
),
}
Some(token)
}
pub fn run(mut cfg: Config) {
info!("Starting sjnmusicd"); info!("Starting sjnmusicd");
let auth_token = resolve_auth_token(&mut cfg);
warn_if_publicly_exposed(&cfg.server.host, auth_token.is_some());
let conn = open_db(); let conn = open_db();
let audio_handle = Arc::new(spawn_audio_thread()); let audio_handle = Arc::new(spawn_audio_thread());
@@ -35,7 +122,7 @@ pub fn run(cfg: Config) {
let server_handle = thread::Builder::new() let server_handle = thread::Builder::new()
.name("sjnmusic-http".into()) .name("sjnmusic-http".into())
.spawn(move || { .spawn(move || {
start_server(server_state, &host, port); start_server(server_state, &host, port, auth_token);
}); });
if let Err(e) = server_handle { if let Err(e) = server_handle {
error!("Failed to spawn HTTP server thread: {e}"); error!("Failed to spawn HTTP server thread: {e}");
+2
View File
@@ -1,4 +1,5 @@
mod audio; mod audio;
mod auth;
mod config; mod config;
mod daemon; mod daemon;
mod db; mod db;
@@ -6,6 +7,7 @@ mod mpris;
mod paths; mod paths;
mod server; mod server;
mod state; mod state;
mod youtube;
fn main() { fn main() {
// Initialise logging first so subsequent startup steps are visible. // Initialise logging first so subsequent startup steps are visible.
+223 -5
View File
@@ -5,7 +5,9 @@ use serde::Serialize;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use crate::audio::AudioCmd; use crate::audio::AudioCmd;
use crate::auth;
use crate::state::{init_async, init_batch, DaemonState, RepeatMode}; use crate::state::{init_async, init_batch, DaemonState, RepeatMode};
use crate::youtube::{pick_best, rank_query, AUTO_PICK_MARGIN};
#[derive(Serialize)] #[derive(Serialize)]
struct ApiError { struct ApiError {
@@ -36,12 +38,24 @@ fn parse_body(body: &str) -> serde_json::Result<serde_json::Value> {
} }
} }
pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) { pub fn start_server(
state: Arc<Mutex<DaemonState>>,
host: &str,
port: u16,
auth_token: Option<String>,
) {
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
let server = match Server::http(&addr) { let server = match Server::http(&addr) {
Ok(s) => { Ok(s) => {
info!("HTTP server listening on http://{addr}"); info!("HTTP server listening on http://{addr}");
info!("Set RUST_LOG=debug for verbose logs"); info!("Set RUST_LOG=debug for verbose logs");
if auth_token.is_some() {
info!(
"Bearer-token authentication enabled (remote peers require Authorization: Bearer <token>)"
);
} else {
info!("Authentication disabled (all peers trusted)");
}
s s
} }
Err(e) => { Err(e) => {
@@ -54,8 +68,11 @@ pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
let state = state.clone(); let state = state.clone();
let url = request.url().to_string(); let url = request.url().to_string();
let method = request.method().clone(); let method = request.method().clone();
debug!("{} {}", method, url); // Clone per request so handlers can borrow it; tiny_http consumes
if let Err(e) = handle(request, state, &method, &url) { // `request` in `respond`, so we can't share a long-lived binding
// across the loop without sending it back in.
let auth_token = auth_token.clone();
if let Err(e) = handle(request, state, &method, &url, &auth_token) {
warn!("Failed to respond: {e}"); warn!("Failed to respond: {e}");
} }
} }
@@ -72,18 +89,37 @@ fn handle(
state: Arc<Mutex<DaemonState>>, state: Arc<Mutex<DaemonState>>,
method: &Method, method: &Method,
url: &str, url: &str,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let path = url.split('?').next().unwrap_or(url); let path = url.split('?').next().unwrap_or(url);
let route = route_for(method, path); let route = route_for(method, path);
// ----------------------------------------------------------------
// Token / pairing endpoints bypass the bearer check but enforce a
// hard localhost requirement: they only respond to loopback peers.
// A non-local request to /auth/token or /auth/pair is a misuse, and
// returning a clear 403 (rather than 401, which would imply "you
// could supply the right credential") avoids confusing attackers
// who probe them from off-host.
// ----------------------------------------------------------------
match route {
Route::AuthTokenGet => return handle_auth_token(request, auth_token),
Route::AuthPair => return handle_auth_pair(request, auth_token),
_ => {}
}
if matches!(route, Route::Help) { if matches!(route, Route::Help) {
let resp = json_response(200, &serde_json::json!({ let resp = json_response(200, &serde_json::json!({
"endpoints": [ "endpoints": [
"GET /help", "GET /help",
"GET /auth/token (localhost-only; returns the bearer token when auth is enabled)",
"POST /auth/pair (localhost-only; pairing-style token endpoint, currently returns the same token as /auth/token)",
"GET /songs?q=", "GET /songs?q=",
"GET /search?q=... (best match) or POST /search {query}", "GET /search?q=... (best match) or POST /search {query}",
"GET /search/all?q=... (every match above threshold)", "GET /search/all?q=... (every match above threshold)",
"GET /search/yt?q=...&limit=N (yt-dlp candidates for picker)", "GET /search/yt?q=...&limit=N (raw yt-dlp candidates for picker)",
"GET /search/yt/ranked?q=...&limit=N (scored + sorted candidates; re-ranks 3 query variants)",
"GET /pick?q=...&limit=N&margin=N (auto-pick when top score > runner-up by margin, otherwise returns candidates)",
"GET /queue", "GET /queue",
"GET /now-playing", "GET /now-playing",
"GET /downloads", "GET /downloads",
@@ -96,6 +132,7 @@ fn handle(
"POST /del {query}", "POST /del {query}",
"POST /init {song} (async, returns job id, status 202)", "POST /init {song} (async, returns job id, status 202)",
"POST /init/batch {songs:[...]}", "POST /init/batch {songs:[...]}",
"POST /play/all",
"POST /skip", "POST /skip",
"POST /pause", "POST /pause",
"POST /resume", "POST /resume",
@@ -119,6 +156,45 @@ fn handle(
return Ok(request.respond(resp)?); return Ok(request.respond(resp)?);
} }
// ----------------------------------------------------------------
// Auth gate for every other route. We deliberately do NOT log the
// Authorization header's value (or even its presence with explicit
// token bytes) — the attacker-supplied bytes are the secret. Only
// the route + method is logged so an operator can correlate
// rejections with client intent.
// ----------------------------------------------------------------
if !auth::request_is_authorized(&request, auth_token.as_deref()) {
debug!(
"auth: rejecting {method} {path} from {} (no/invalid token)",
request
.remote_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|| "<unknown>".to_string())
);
let mut resp = json_response(
401,
&ApiError {
error: "unauthorized".to_string(),
},
);
// Advertise the auth scheme to well-behaved clients (the CLI's
// auto-fetch fallback, future GUI implementations, third-party
// tools). The `realm` is purely informational; the daemon
// doesn't currently namespace per-user, so a single realm label
// is sufficient. We only attach the header when authentication
// IS actually enabled, so a 401 from a daemon with auth off
// doesn't advertise a mechanism that wouldn't accept a token.
if auth_token.is_some() {
if let Ok(h) = Header::from_bytes(
&b"WWW-Authenticate"[..],
&b"Bearer realm=\"sjnmusicd\""[..],
) {
resp.add_header(h);
}
}
return Ok(request.respond(resp)?);
}
let mut body_text = String::new(); let mut body_text = String::new();
if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) { if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) {
match read_body(&mut request) { match read_body(&mut request) {
@@ -149,6 +225,13 @@ fn handle(
let response = match route { let response = match route {
Route::Help => unreachable!(), Route::Help => unreachable!(),
// /auth/token and /auth/pair are intercepted at the top of
// `handle()` (before the auth gate) and short-circuited with an
// explicit `return`. After that gate they cannot reach this
// dispatch block, so the arms below are defensive.
Route::AuthTokenGet | Route::AuthPair => unreachable!(
"/auth/token and /auth/pair are intercepted before this match"
),
Route::Songs => { Route::Songs => {
let q = query_params.get("q").map(|s| s.as_str()); let q = query_params.get("q").map(|s| s.as_str());
@@ -180,7 +263,15 @@ fn handle(
.get("limit") .get("limit")
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
.unwrap_or(state.search_count); .unwrap_or(state.search_count);
match state.search_yt_sync(&q, limit) { // Release the synchronous state guard BEFORE calling
// search_yt_sync — yt-dlp can take 30+ seconds on a slow
// network and holding the daemon mutex here would freeze
// every other endpoint (including the GUI's `/init`,
// `/downloads`, `/now-playing` polls) for the entire
// duration. Mirrors the drop-then-call pattern that
// `init_sync` already uses.
drop(state);
match DaemonState::search_yt_sync(&q, limit) {
Ok(results) => json_response(200, &serde_json::json!({ Ok(results) => json_response(200, &serde_json::json!({
"query": q, "query": q,
"limit": limit, "limit": limit,
@@ -192,6 +283,48 @@ fn handle(
Err(e) => error_response(e), Err(e) => error_response(e),
}, },
Route::SearchYtRanked => match query_or_body(&query_params, &body_text) {
Ok(q) => {
let limit = query_params
.get("limit")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(state.search_count);
// Same lock-drop reasoning as `/search/yt` — parallel
// yt-dlp invocations can reach 30s+ each before they
// return, and we don't want to keep the state mutex for
// the duration.
drop(state);
match rank_query(&q, limit) {
Ok(ranked) => json_response(200, &serde_json::json!({
"query": q,
"limit": limit,
"results": ranked,
})),
Err(e) => error_response(e),
}
}
Err(e) => error_response(e),
},
Route::Pick => match query_or_body(&query_params, &body_text) {
Ok(q) => {
let limit = query_params
.get("limit")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(state.search_count);
let margin = query_params
.get("margin")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(AUTO_PICK_MARGIN);
drop(state);
match pick_best(&q, limit, margin) {
Ok(resp) => json_response(200, &resp),
Err(e) => error_response(e),
}
}
Err(e) => error_response(e),
},
Route::Queue => json_response(200, &serde_json::json!({ Route::Queue => json_response(200, &serde_json::json!({
"queue": state.queue, "queue": state.queue,
"current": state.current, "current": state.current,
@@ -280,6 +413,14 @@ fn handle(
Err(e) => error_response(e), Err(e) => error_response(e),
}, },
Route::PlayAll => match state.play_all_random() {
Ok(len) => json_response(
200,
&serde_json::json!({ "queued": len }),
),
Err(e) => error_response(e),
},
Route::Skip => { Route::Skip => {
state.audio_handle.send(AudioCmd::Stop); state.audio_handle.send(AudioCmd::Stop);
// Always clear current — a skip is an explicit decision to // Always clear current — a skip is an explicit decision to
@@ -437,6 +578,73 @@ fn json_response_now_playing(state: &DaemonState) -> Response<std::io::Cursor<Ve
) )
} }
fn handle_auth_token(
request: Request,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if !auth::is_local_request(&request) {
let resp = json_response(
403,
&ApiError {
error: "/auth/token is reachable only from loopback".to_string(),
},
);
return Ok(request.respond(resp)?);
}
match auth_token.as_deref() {
Some(t) => {
let resp = json_response(200, &serde_json::json!({ "token": t }));
Ok(request.respond(resp)?)
}
None => {
// No token configured → authentication is off. We expose
// this explicitly so the CLI knows not to bother retrying.
let resp = json_response(
404,
&ApiError {
error: "authentication disabled (no token configured)".to_string(),
},
);
Ok(request.respond(resp)?)
}
}
}
fn handle_auth_pair(
request: Request,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if !auth::is_local_request(&request) {
let resp = json_response(
403,
&ApiError {
error: "/auth/pair is reachable only from loopback".to_string(),
},
);
return Ok(request.respond(resp)?);
}
// The full pairing endpoint can in future rotate / generate /
// revoke. Today it mirrors /auth/token: token is generated at
// daemon startup and persists in `~/.sjn/music/config.toml`, so
// there is nothing to do here. The endpoint still exists so future
// servers can extend it without a CLI breaking change.
match auth_token.as_deref() {
Some(t) => {
let resp = json_response(200, &serde_json::json!({ "token": t }));
Ok(request.respond(resp)?)
}
None => {
let resp = json_response(
404,
&ApiError {
error: "authentication disabled (no token configured)".to_string(),
},
);
Ok(request.respond(resp)?)
}
}
}
#[derive(Debug)] #[derive(Debug)]
enum AudioRoute { enum AudioRoute {
Pause, Pause,
@@ -471,10 +679,14 @@ fn handle_audio_route(
#[derive(Debug)] #[derive(Debug)]
enum Route { enum Route {
Help, Help,
AuthTokenGet,
AuthPair,
Songs, Songs,
Search, Search,
SearchAll, SearchAll,
SearchYt, SearchYt,
SearchYtRanked,
Pick,
Queue, Queue,
NowPlaying, NowPlaying,
Downloads, Downloads,
@@ -485,6 +697,7 @@ enum Route {
Del, Del,
InitAsync, InitAsync,
InitBatch, InitBatch,
PlayAll,
Skip, Skip,
QueueShuffle, QueueShuffle,
QueueClear, QueueClear,
@@ -510,9 +723,13 @@ fn route_for(method: &Method, path: &str) -> Route {
let p = path.trim_end_matches('/'); let p = path.trim_end_matches('/');
match (method, p) { match (method, p) {
(Method::Get, "") | (Method::Get, "/help") => Route::Help, (Method::Get, "") | (Method::Get, "/help") => Route::Help,
(Method::Get, "/auth/token") => Route::AuthTokenGet,
(Method::Post, "/auth/pair") => Route::AuthPair,
(Method::Get, "/songs") => Route::Songs, (Method::Get, "/songs") => Route::Songs,
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search, (m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
(Method::Get, "/search/all") => Route::SearchAll, (Method::Get, "/search/all") => Route::SearchAll,
(Method::Get, "/search/yt/ranked") => Route::SearchYtRanked,
(Method::Get, "/pick") => Route::Pick,
(Method::Get, "/search/yt") => Route::SearchYt, (Method::Get, "/search/yt") => Route::SearchYt,
(Method::Get, "/queue") => Route::Queue, (Method::Get, "/queue") => Route::Queue,
(Method::Get, "/now-playing") => Route::NowPlaying, (Method::Get, "/now-playing") => Route::NowPlaying,
@@ -524,6 +741,7 @@ fn route_for(method: &Method, path: &str) -> Route {
(Method::Post, "/del") => Route::Del, (Method::Post, "/del") => Route::Del,
(Method::Post, "/init") => Route::InitAsync, (Method::Post, "/init") => Route::InitAsync,
(Method::Post, "/init/batch") => Route::InitBatch, (Method::Post, "/init/batch") => Route::InitBatch,
(Method::Post, "/play/all") => Route::PlayAll,
(Method::Post, "/skip") => Route::Skip, (Method::Post, "/skip") => Route::Skip,
(Method::Post, "/queue/shuffle") => Route::QueueShuffle, (Method::Post, "/queue/shuffle") => Route::QueueShuffle,
(Method::Post, "/queue/clear") => Route::QueueClear, (Method::Post, "/queue/clear") => Route::QueueClear,
+44 -1
View File
@@ -450,6 +450,40 @@ impl DaemonState {
Ok(song) Ok(song)
} }
/// Replace the queue with a shuffled copy of the entire library.
///
/// Mirrors `/playlists/{name}/play`: clear the queue, re-insert in
/// random order, persist to the DB, and refresh `cycle_snapshot` so
/// `RepeatMode::All` keeps looping over the same shuffle. The daemon
/// tick loop picks up the new head of queue and starts playback
/// automatically once the audio engine is idle (same model as
/// `/play`); calling `play_all_random` while a song is playing only
/// queues the rest of the library behind the current track.
///
/// Returns the number of songs queued. An empty library surfaces
/// `"library is empty"` to the HTTP layer so the GUI/CLI can render
/// a useful toast instead of silently doing nothing.
pub fn play_all_random(&mut self) -> Result<usize, String> {
let mut shuffled = self.all_songs();
if shuffled.is_empty() {
return Err("library is empty".to_string());
}
use rand::seq::SliceRandom;
shuffled.shuffle(&mut rand::thread_rng());
{
let conn = self.lock_conn();
Self::queue_clear_db(&conn).map_err(|e| e.to_string())?;
for s in &shuffled {
Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?;
}
}
let len = shuffled.len();
self.queue = shuffled;
self.cycle_snapshot = self.queue.clone();
info!("Play-all: queued {len} songs in random order");
Ok(len)
}
/// In-place shuffle of the current queue. /// In-place shuffle of the current queue.
pub fn shuffle_queue(&mut self) -> Result<(), String> { pub fn shuffle_queue(&mut self) -> Result<(), String> {
if self.queue.len() < 2 { if self.queue.len() < 2 {
@@ -556,7 +590,16 @@ impl DaemonState {
/// non-zero exit are tolerated as much as possible: an empty list is /// non-zero exit are tolerated as much as possible: an empty list is
/// returned when nothing parsed, while a yt-dlp startup failure surfaces /// returned when nothing parsed, while a yt-dlp startup failure surfaces
/// a descriptive error string so the CLI can show it to the user. /// a descriptive error string so the CLI can show it to the user.
pub fn search_yt_sync(&self, query: &str, limit: usize) -> Result<Vec<YtCandidate>, String> { ///
/// Lives as an associated function (no `&self`) so the HTTP handler can
/// release the daemon state mutex before spawning yt-dlp. Earlier
/// revisions invoked this as `state.search_yt_sync(...)` *while*
/// holding the single-threaded `tiny_http` request handler's state
/// guard, which froze the entire daemon (including `/init`, `/queue`,
/// `/now-playing`) for the full duration of a yt-dlp search — typically
/// 30+ seconds on a slow network. Mirrors the lock-free pattern already
/// used for `init_sync` below.
pub fn search_yt_sync(query: &str, limit: usize) -> Result<Vec<YtCandidate>, String> {
let limit = limit.max(1); let limit = limit.max(1);
let src = format!("ytsearch{limit}:{query}"); let src = format!("ytsearch{limit}:{query}");
debug!("yt-dlp search: {src}"); debug!("yt-dlp search: {src}");
+927
View File
@@ -0,0 +1,927 @@
//! YouTube candidate ranking + auto-pick heuristics.
//!
//! Wraps `DaemonState::search_yt_sync` with a layer that pushes the most
//! likely "official" track to the top of the candidate list, so the user
//! rarely needs to manually pick a result.
//!
//! ## Why
//!
//! `yt-dlp -j ytsearch5:"<artist> <title>"` returns whatever YouTube's
//! own search-order decides (closely tied to view count, recency, and
//! relative engagement) — which means a 5-year-old fan-upload with
//! "Lyrics" in the title often outranks the official 2024 upload from
//! the artist's own channel. The ranking heuristics here re-score
//! every candidate with explicit signals so the official track wins
//! in the common case.
//!
//! ## Pipeline
//!
//! 1. [`query_variants`] expands `<artist> <title>` into 3 ytsearch
//! queries: raw, "official video", "official audio". Each variant
//! biases toward the channel pattern YouTube's official uploads
//! follow ("Artist - Topic", "VEVO", "(Official)").
//! 2. [`rank_query`] runs every variant **in parallel** (each spawns
//! its own yt-dlp subprocess), merges, dedupes, scores, and returns
//! the top-`limit` sorted by score.
//! 3. [`pick_best`] adds the auto-pick decision on top: when the top
//! score beats the runner-up by [`AUTO_PICK_MARGIN`] points, the
//! caller can proceed straight to download without surfacing a
//! picker.
//!
//! Scoring constants live next to the scorer so the spec in the repo
//! docs and the code can never drift apart — adjust the numbers here
//! when refining the heuristics.
use std::collections::HashSet;
use std::thread;
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use strsim::jaro_winkler;
use crate::state::{DaemonState, YtCandidate};
// =====================================================================
// Scoring constants — tuned to the spec in /docs/INTERFACE.md (search
// ranking heuristics). Pull these into your editor and read them next
// to `score_candidate` when adjusting values.
// =====================================================================
/// Official-artist-channel marker. Bigger than every other positive
/// signal combined so a real official upload will always outrank even
/// the most popular fan upload.
pub const OFFICIAL_CHANNEL_BOOST: i32 = 100;
/// "Official Video" / "Official Music Video" / "Official Audio" phrase
/// in the title. Stacks with `OFFICIAL_CHANNEL_BOOST`.
pub const OFFICIAL_PHRASE_BOOST: i32 = 30;
/// All (non-trivial) tokens from the parsed query appear in the
/// candidate title. Stops "Best Of" compilations and audio-twin
/// decoys from outscoring the real track.
pub const TITLE_INCLUDES_QUERY_BOOST: i32 = 40;
/// Duration sits in the 2–4 minute "ideal" band.
pub const DURATION_IDEAL_BOOST: i32 = 20;
/// Duration sits in the 1.5–6 minute "acceptable" band (excluding
/// ideal).
pub const DURATION_ACCEPTABLE_BOOST: i32 = 10;
/// Duration is over 8 minutes (audiobooks, full live sets).
pub const DURATION_LONG_PENALTY: i32 = -30;
/// Duration is suspiciously short (< 1 minute).
pub const DURATION_SHORT_PENALTY: i32 = -15;
/// yt-dlp didn't report a duration at all (livestream, region-locked).
pub const DURATION_UNKNOWN_PENALTY: i32 = -5;
/// Tokens like "Lyrics", "Lyric Video" in the title.
pub const LYRICS_PENALTY: i32 = -40;
/// Reaction channels (Reaction / Reacts / First Listen).
pub const REACTION_PENALTY: i32 = -80;
/// "Remix" / "Edit" / "Mashup" — skipped when the user typed "remix".
pub const REMIX_PENALTY: i32 = -50;
/// "Live" / "Concert" — skipped when the user typed "live".
pub const LIVE_PENALTY: i32 = -40;
/// Nightcore / Bass Boosted / Slowed / Reverb. These are spam-flag
/// genres; skipping is unconditional because that's not how anybody
/// searches for music they actually want.
pub const SPAM_PENALTY: i32 = -80;
/// "Instrumental" — skipped when the user typed "instrumental".
pub const INSTRUMENTAL_PENALTY: i32 = -60;
/// "Karaoke" — always penalised.
pub const KARAOKE_PENALTY: i32 = -60;
/// Requested artist does not fuzzy-match the candidate's
/// uploader / title at all.
pub const ARTIST_MISMATCH_PENALTY: i32 = -60;
/// Small penalty applied to fan uploads when at least one
/// genuinely-official upload (`VEVO`, `- Topic`, `(Official)`)
/// exists in the candidate set. Disappears when no official
/// candidate was found at all, so genuinely obscure tracks aren't
/// over-penalised. Spec: "Fan uploads: Small penalty unless no
/// official upload exists."
pub const FAN_UPLOAD_PENALTY: i32 = -10;
/// Below this Jaro-Winkler similarity the artist match is treated as
/// "missing" → `ARTIST_MISMATCH_PENALTY`.
pub const ARTIST_MATCH_REJECT: f64 = 0.65;
// Duration thresholds (seconds). Spec bands:
// ideal = 2–4 min → +20
// acceptable = 2–6 min, excluding ideal → +10
// long = > 8 min → penalty
pub const IDEAL_SONG_SECS: f64 = 120.0;
pub const IDEAL_SONG_SECS_MAX: f64 = 240.0;
pub const MIN_SONG_SECS: f64 = 120.0;
pub const MAX_SONG_SECS: f64 = 360.0;
pub const LONG_SONG_SECS: f64 = 480.0;
pub const VERY_SHORT_SECS: f64 = 60.0;
/// Margin (in score points) by which the top candidate must beat the
/// runner-up before `pick_best` decides to auto-select. Matches the
/// "best by ≥30" example in the spec.
pub const AUTO_PICK_MARGIN: i32 = 30;
/// Extra candidates fetched per variant beyond what the caller asked
/// for, to leave headroom after dedupe + scoring.
pub const PER_VARIANT_LIMIT_BUMP: usize = 2;
/// Lower bound on per-variant ytsearch limit even when the caller asks
/// for fewer.
pub const MIN_PER_VARIANT_LIMIT: usize = 3;
// =====================================================================
// ParsedQuery — split `<artist> <title>` into proper halves so we can
// do artist-matching without trusting only the title.
// =====================================================================
/// Parsed-out (artist, title, raw) for ranking and artist-matching.
/// `artist` is `None` when the query can't be split cleanly; the
/// scorer just skips the artist mismatch penalty in that case rather
/// than guessing.
#[derive(Clone, Debug)]
pub struct ParsedQuery {
pub artist: Option<String>,
pub title: String,
/// The full input the user typed, kept verbatim so we can detect
/// `remix`, `live`, `instrumental`, etc. user-intent modifiers.
pub raw: String,
}
impl ParsedQuery {
/// Try common separators in order. Falls back to "all of the
/// query is the title, no artist info" when nothing matches.
/// Returns `None` for empty/whitespace input so the ranker
/// can short-circuit (ytsearch has nothing useful to ask for).
///
/// Accepts both spaced and bare separators so copy-pasted
/// metadata like `Artist:Title` (no surrounding spaces) and
/// `Artist - Title` both parse cleanly. Tokenizing beyond
/// the first occurrence is intentional — callers can always
/// pass the raw query if the heuristic misfires.
pub fn parse(query: &str) -> Option<Self> {
let q = query.trim();
if q.is_empty() {
return None;
}
for sep in [" - ", " | "] {
if let Some(idx) = q.find(sep) {
let a = q[..idx].trim();
let t = q[idx + sep.len()..].trim();
if !a.is_empty() && !t.is_empty() {
return Some(Self {
artist: Some(a.to_string()),
title: t.to_string(),
raw: q.to_string(),
});
}
}
}
// Colon: any of ` : `, ` :`, `: `, or bare `:` — handlers
// trim afterwards, so whitespace variants collapse to the
// same split.
if let Some(idx) = q.find(':') {
let a = q[..idx].trim();
let t = q[idx + 1..].trim();
if !a.is_empty() && !t.is_empty() {
return Some(Self {
artist: Some(a.to_string()),
title: t.to_string(),
raw: q.to_string(),
});
}
}
Some(Self {
artist: None,
title: q.to_string(),
raw: q.to_string(),
})
}
}
// =====================================================================
// Query variants — expand a query into 3 ytsearch calls that together
// surface both the user's own-channel uploads and YouTube's
// auto-generated "Topic" channels.
// =====================================================================
/// Expand `<artist> <title>` into a small batch of ytsearch-friendly
/// queries. The first element is always the raw query (so dedup-by-id
/// can collapse matches across variants). Subsequent entries bias
/// toward the "tagged Artist's <Title> [Official…]" pattern that
/// official uploads follow.
pub fn query_variants(raw: &str) -> Vec<String> {
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
let stripped = strip_official_suffix(raw);
let mut out = vec![
stripped.to_string(),
format!("{stripped} official video"),
format!("{stripped} official audio"),
];
let mut seen = HashSet::new();
out.retain(|v| seen.insert(v.clone()));
out
}
/// Strip a trailing "official video" / "official music video" /
/// "official audio" suffix from the query so re-adding it doesn't
/// double the term.
fn strip_official_suffix(s: &str) -> &str {
let lower = s.to_ascii_lowercase();
for suf in ["official music video", "official video", "official audio"] {
if let Some(rest) = lower.strip_suffix(suf) {
let trimmed = rest.trim_end_matches(|c: char| c == '-' || c.is_whitespace());
return s[..trimmed.len()].trim_end();
}
}
s
}
// =====================================================================
// ScoreBreakdown — every scoring field, kept around for the GUI/CLI
// to render "why was this picked" tooltips.
// =====================================================================
/// Per-candidate score breakdown that the ranker and the GUI/CLI both
/// read. `total` is what sorting + auto-pick decisions use; the rest
/// are surfaced as badges.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct ScoreBreakdown {
pub total: i32,
#[serde(default)]
pub official_channel: i32,
#[serde(default)]
pub official_phrase: i32,
#[serde(default)]
pub title_match: i32,
#[serde(default)]
pub artist_match: i32,
#[serde(default)]
pub duration: i32,
#[serde(default)]
pub fan_upload: i32,
#[serde(default)]
pub lyrics: i32,
#[serde(default)]
pub reaction: i32,
#[serde(default)]
pub remix: i32,
#[serde(default)]
pub live: i32,
#[serde(default)]
pub spam: i32,
#[serde(default)]
pub instrumental: i32,
#[serde(default)]
pub karaoke: i32,
}
/// YtCandidate + score + flags. `score` is the breakdown `total` so
/// consumers can sort cheaply; the full `breakdown` is included for
/// the GUI's "why was this picked?" tooltip.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RankedCandidate {
#[serde(flatten)]
pub base: YtCandidate,
pub score: i32,
/// Full score breakdown so the GUI/CLI can render "official +35",
/// "lyrics -40" etc. `None` if the scorer short-circuited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breakdown: Option<ScoreBreakdown>,
/// Detected badges (`official`, `lyrics`, `live`, `reaction`,
/// `remix`, `nightcore`, `slowed`, `bass-boosted`, `reverb`,
/// `instrumental`, `karaoke`, `long`, `short`,
/// `artist-mismatch`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
}
// =====================================================================
// score_candidate — the heart of the ranking. Returns (breakdown, flags).
// =====================================================================
/// Score a single yt-dlp candidate against the parsed query.
/// Mutates everyone-but-flags of `ScoreBreakdown::total` at the end.
pub fn score_candidate(pq: &ParsedQuery, c: &YtCandidate) -> (ScoreBreakdown, Vec<String>) {
let mut s = ScoreBreakdown::default();
let mut flags: Vec<String> = Vec::new();
let raw_lower = pq.raw.to_ascii_lowercase();
let title_lower = c.title.to_ascii_lowercase();
let uploader_lower = c.uploader.to_ascii_lowercase();
// ---- Positive signals ----------------------------------------
// Official artist channel.
// YouTube's auto-generated "Topic" channels (e.g. "Artist - Topic")
// are official uploads by definition — the copyright holder
// registered them via YouTube's Content ID. VEVO is the legacy
// official channel provider. "(Official)" / "Official Artist
// Channel" appear in the uploader string when YouTube has flagged
// the channel.
let official_markers = ["vevo", " - topic", "(official)", "official artist channel"];
if official_markers.iter().any(|kw| uploader_lower.contains(kw)) {
s.official_channel = OFFICIAL_CHANNEL_BOOST;
flags.push("official".to_string());
}
// Title contains ALL non-trivial tokens from the parsed query.
let mut required: Vec<String> = pq
.title
.split_whitespace()
.filter(|w| w.len() > 2)
.map(|w| w.to_ascii_lowercase())
.collect();
if let Some(a) = &pq.artist {
for w in a.split_whitespace().filter(|w| w.len() > 2) {
required.push(w.to_ascii_lowercase());
}
}
if !required.is_empty() && required.iter().all(|t| title_lower.contains(t)) {
s.title_match = TITLE_INCLUDES_QUERY_BOOST;
}
// "Official Video" / "Official Music Video" / "Official Audio" in title.
let official_phrases = ["official music video", "official video", "official audio"];
if official_phrases.iter().any(|p| title_lower.contains(p)) {
s.official_phrase = OFFICIAL_PHRASE_BOOST;
if !flags.iter().any(|f| f == "official") {
// Only push the "official-upload" tag if we didn't already
// tag the channel as official — keeps the badge list short.
flags.push("official-upload".to_string());
}
}
// Artist matching: requested artist vs uploader / title / channel.
// `pick_uploader` in state.rs prefers the channel string, so the
// `uploader` field IS the channel-name-equivalent for most videos.
if let Some(artist) = &pq.artist {
let artist_lower = artist.to_ascii_lowercase();
if !artist_lower.is_empty() {
let sim_uploader = jaro_winkler(&artist_lower, &uploader_lower);
let sim_title = jaro_winkler(&artist_lower, &title_lower);
let best = sim_uploader.max(sim_title);
if best < ARTIST_MATCH_REJECT {
s.artist_match = ARTIST_MISMATCH_PENALTY;
flags.push("artist-mismatch".to_string());
}
}
}
// Duration scoring.
let dur = c.duration_secs;
if dur <= 0.0 {
s.duration = DURATION_UNKNOWN_PENALTY;
} else if dur >= IDEAL_SONG_SECS && dur <= IDEAL_SONG_SECS_MAX {
s.duration = DURATION_IDEAL_BOOST;
} else if dur >= MIN_SONG_SECS && dur <= MAX_SONG_SECS {
s.duration = DURATION_ACCEPTABLE_BOOST;
} else if dur > LONG_SONG_SECS {
s.duration = DURATION_LONG_PENALTY;
flags.push("long".to_string());
} else if dur < VERY_SHORT_SECS {
s.duration = DURATION_SHORT_PENALTY;
flags.push("short".to_string());
}
// ---- Negative signals -----------------------------------------
// Each has a corresponding "user opted in" guard so a search for
// "song remix" doesn't penalise the remix itself.
let user_wants = |kw: &str| raw_lower.contains(kw);
let title_has = |terms: &[&str]| terms.iter().any(|t| title_lower.contains(t));
if !user_wants("lyric") && title_has(&["lyric", "lyrics"]) {
s.lyrics = LYRICS_PENALTY;
flags.push("lyrics".to_string());
}
if !user_wants("reaction") && title_has(&["reaction", "reacts", "first listen"]) {
s.reaction = REACTION_PENALTY;
flags.push("reaction".to_string());
}
if !user_wants("remix") && title_has(&["remix", "edit", "mashup"]) {
s.remix = REMIX_PENALTY;
flags.push("remix".to_string());
}
if !user_wants("live") && title_has(&["live", "concert"]) {
s.live = LIVE_PENALTY;
flags.push("live".to_string());
}
if title_has(&["nightcore"]) {
s.spam += SPAM_PENALTY;
flags.push("nightcore".to_string());
}
if title_has(&["bass boosted"]) {
s.spam += SPAM_PENALTY;
flags.push("bass-boosted".to_string());
}
if title_has(&["slowed"]) {
s.spam += SPAM_PENALTY;
flags.push("slowed".to_string());
}
if title_has(&["reverb"]) {
s.spam += SPAM_PENALTY;
flags.push("reverb".to_string());
}
if !user_wants("instrumental") && title_has(&["instrumental"]) {
s.instrumental = INSTRUMENTAL_PENALTY;
flags.push("instrumental".to_string());
}
if title_has(&["karaoke"]) {
s.karaoke = KARAOKE_PENALTY;
flags.push("karaoke".to_string());
}
// `fan_upload` is updated by `apply_fan_upload_penalty` after
// scoring; deliberately omitted from the initial total sum so
// the post-pass owns the bookkeeping and syncs `rc.score = b.total`.
s.total = s.official_channel
+ s.official_phrase
+ s.title_match
+ s.artist_match
+ s.duration
+ s.lyrics
+ s.reaction
+ s.remix
+ s.live
+ s.spam
+ s.instrumental
+ s.karaoke;
(s, flags)
}
// =====================================================================
// dedupe_candidates — drop exact-id duplicates first, then near-by
// title + uploader copies so VEVO + Topic + "Full Album" copies of
// the same track collapse into one ranked row.
// =====================================================================
pub fn dedupe_candidates(candidates: Vec<YtCandidate>) -> Vec<YtCandidate> {
let mut out: Vec<YtCandidate> = Vec::new();
for c in candidates {
if out.iter().any(|existing| existing.id == c.id) {
continue;
}
let dup = out.iter().any(|existing| {
let t_sim = jaro_winkler(
&existing.title.to_ascii_lowercase(),
&c.title.to_ascii_lowercase(),
);
let u_sim = jaro_winkler(
&existing.uploader.to_ascii_lowercase(),
&c.uploader.to_ascii_lowercase(),
);
t_sim > 0.85 && u_sim > 0.70
});
if !dup {
out.push(c);
}
}
out
}
// =====================================================================
// Fan-upload penalty — small -10 to non-official candidates when at
// least one official upload exists in the result set. Spec line:
// "Fan uploads: Small penalty unless no official upload exists."
// =====================================================================
/// Apply `FAN_UPLOAD_PENALTY` to every candidate that is NOT marked
/// as an official channel upload, when the result set contains at
/// least one such official candidate. No-op otherwise.
pub fn apply_fan_upload_penalty(ranked: &mut [RankedCandidate]) {
let has_official = ranked.iter().any(|rc| {
rc.flags
.iter()
.any(|f| f == "official" || f == "official-upload")
});
if !has_official {
return;
}
for rc in ranked.iter_mut() {
let is_official = rc
.flags
.iter()
.any(|f| f == "official" || f == "official-upload");
if is_official {
continue;
}
// Single-source the bookkeeping: mutate the breakdown first,
// then sync `rc.score` to it so the two never drift.
if let Some(b) = rc.breakdown.as_mut() {
b.fan_upload += FAN_UPLOAD_PENALTY;
b.total += FAN_UPLOAD_PENALTY;
rc.score = b.total;
} else {
rc.score += FAN_UPLOAD_PENALTY;
}
if !rc.flags.iter().any(|f| f == "fan-upload") {
rc.flags.push("fan-upload".to_string());
}
}
}
// =====================================================================
// rank_query — fetch all variants in parallel, merge, dedupe, score.
// =====================================================================
/// Run every variant in parallel, merge + dedupe + score, return the
/// top-`limit` candidates sorted by score descending. An empty query
/// returns an empty list (no error — the ranker is happy to be
/// no-op'd). yt-dlp subprocess failures are logged and the surviving
/// variants still produce results.
pub fn rank_query(query: &str, limit: usize) -> Result<Vec<RankedCandidate>, String> {
let pq = match ParsedQuery::parse(query) {
Some(pq) => pq,
None => return Ok(Vec::new()),
};
let variants = query_variants(&pq.raw);
if variants.is_empty() {
return Ok(Vec::new());
}
let per_variant_limit = (limit + PER_VARIANT_LIMIT_BUMP).max(MIN_PER_VARIANT_LIMIT);
debug!(
"rank_query: query={:?}, {} variants, per-variant limit {}",
query,
variants.len(),
per_variant_limit
);
// Spawn one thread per variant. Each thread calls yt-dlp
// independently and returns its parsed candidate list. yt-dlp
// subprocesses don't share state, so this is safe and skips the
// 3x latency hit of fetching variants serially.
let threads: Vec<_> = variants
.iter()
.map(|v| {
let v = v.clone();
thread::Builder::new()
.name(format!("sjnmusic-rank-{}", short_token(&v)))
.spawn(move || {
DaemonState::search_yt_sync(&v, per_variant_limit).unwrap_or_default()
})
})
.collect();
let mut all: Vec<YtCandidate> = Vec::new();
for t in threads {
match t {
Ok(handle) => match handle.join() {
Ok(mut r) => all.append(&mut r),
Err(_) => warn!("rank_query: variant fetcher thread panicked"),
},
Err(e) => warn!("rank_query: spawn failed: {e}"),
}
}
let deduped = dedupe_candidates(all);
debug!(
"rank_query: {} unique candidates after dedupe (from {} raw)",
deduped.len(),
variants.len() * per_variant_limit
);
let mut ranked: Vec<RankedCandidate> = deduped
.into_iter()
.map(|c| {
let (score, flags) = score_candidate(&pq, &c);
RankedCandidate {
base: c,
score: score.total,
breakdown: Some(score),
flags,
}
})
.collect();
// Fan-upload penalty pass: small -10 to non-official candidates
// when at least one official upload exists. Spec signal: "Fan
// uploads: Small penalty unless no official upload exists."
apply_fan_upload_penalty(&mut ranked);
// Stable-sort descending by score → yt-dlp's original order is
// the tiebreaker so the most-viewcount-heavy track wins among
// equals (e.g. all four variants returning the same video).
ranked.sort_by(|a, b| b.score.cmp(&a.score));
if limit > 0 && ranked.len() > limit {
ranked.truncate(limit);
}
Ok(ranked)
}
/// Truncate a query for use in a thread name. yt-dlp may spit out
/// queries that confuse thread-name limits otherwise.
fn short_token(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric())
.take(20)
.collect::<String>()
.to_ascii_lowercase()
}
// =====================================================================
// PickResponse — the wire shape of the /pick endpoint. Tagged enum so
// CLI/GUI can branch on `kind` without merging two response shapes.
// =====================================================================
/// Response from the `/pick` endpoint. Tagged enum → JSON like
/// `{"kind": "auto", ...}` or `{"kind": "needs_choice", ...}` so the
/// CLI can branch on the discriminator alone.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PickResponse {
/// Single confident pick — caller can proceed straight to
/// `/init` with `url` and skip showing the picker.
Auto {
url: String,
title: String,
uploader: String,
score: i32,
duration_secs: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
flags: Vec<String>,
},
/// Two or more candidates are close in score — caller should
/// show a picker.
NeedsChoice {
candidates: Vec<RankedCandidate>,
top_score: i32,
runner_up_score: i32,
margin: i32,
},
/// yt-dlp returned nothing usable; caller should report an error.
Empty { query: String, message: String },
}
/// Decide whether to auto-pick or surface the picker. Uses
/// [`rank_query`] under the hood. `limit` controls how many ranked
/// candidates the picker case carries; `margin` is the auto-pick
/// threshold (defaults to [`AUTO_PICK_MARGIN`]).
pub fn pick_best(query: &str, limit: usize, margin: i32) -> Result<PickResponse, String> {
// Fetch a couple extra candidates so the runner-up comparison has
// real signal rather than fighting with tail-end noise.
let fetch = (limit.max(2) + 2).max(5);
let ranked = rank_query(query, fetch)?;
if ranked.is_empty() {
return Ok(PickResponse::Empty {
query: query.to_string(),
message: format!("no yt-dlp results for {query:?}"),
});
}
let top = &ranked[0];
if ranked.len() == 1 {
return Ok(PickResponse::Auto {
url: top.base.url.clone(),
title: top.base.title.clone(),
uploader: top.base.uploader.clone(),
score: top.score,
duration_secs: top.base.duration_secs,
flags: top.flags.clone(),
});
}
let runner_up = ranked[1].score;
let actual_margin = top.score - runner_up;
if actual_margin >= margin {
return Ok(PickResponse::Auto {
url: top.base.url.clone(),
title: top.base.title.clone(),
uploader: top.base.uploader.clone(),
score: top.score,
duration_secs: top.base.duration_secs,
flags: top.flags.clone(),
});
}
// Detach the borrow on `ranked` before moving it into `trimmed` —
// otherwise Rust insists the auto branches above have already
// borrowed the very same slot we're trying to move into the
// NeedsChoice payload.
let top_score = top.score;
let trimmed = if limit > 0 && ranked.len() > limit {
ranked[..limit].to_vec()
} else {
ranked
};
Ok(PickResponse::NeedsChoice {
candidates: trimmed,
top_score,
runner_up_score: runner_up,
margin: actual_margin,
})
}
// =====================================================================
// Tests.
//
// Pure-function logic tests; the IO-heavy paths (rank_query, pick_best)
// are covered manually via the daemon's `/search/yt/ranked` and
// `/pick` endpoints, since mocking yt-dlp in-process adds more complexity
// than the integration value is worth. The scorer / dedupe / parser
// paths are deterministic and below.
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
fn cand(title: &str, uploader: &str, dur: f64) -> YtCandidate {
YtCandidate {
id: title.to_string(),
title: title.to_string(),
uploader: uploader.to_string(),
duration_secs: dur,
url: format!("https://example/{title}"),
thumbnail: None,
}
}
#[test]
fn parses_artist_title_with_dash_separator() {
let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap();
assert_eq!(pq.artist.as_deref(), Some("Haftbefehl"));
assert_eq!(pq.title, "RADW");
}
#[test]
fn parses_artist_title_with_colon_separator() {
let pq = ParsedQuery::parse("Bonez MC: Erde").unwrap();
assert_eq!(pq.artist.as_deref(), Some("Bonez MC"));
assert_eq!(pq.title, "Erde");
}
#[test]
fn parses_no_separator_artist_is_none() {
let pq = ParsedQuery::parse("Just A Title").unwrap();
assert_eq!(pq.artist, None);
assert_eq!(pq.title, "Just A Title");
}
#[test]
fn empty_query_returns_none() {
assert!(ParsedQuery::parse(" ").is_none());
assert!(ParsedQuery::parse("").is_none());
}
#[test]
fn query_variants_strips_duplicate_official_suffix() {
let v = query_variants("Haftbefehl - RADW official audio");
// First entry has the suffix removed; the rest re-add it. The
// trailing "official audio" duplication is collapsed.
assert!(v[0] == "Haftbefehl - RADW");
assert!(v.contains(&"Haftbefehl - RADW official video".to_string()));
assert_eq!(v.len(), 3);
}
#[test]
fn query_variants_empty_returns_empty() {
assert!(query_variants("").is_empty());
assert!(query_variants(" ").is_empty());
}
#[test]
fn official_topic_channel_boosts_hard() {
let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap();
let c = cand("Haftbefehl - RADW", "Haftbefehl - Topic", 200.0);
let (s, flags) = score_candidate(&pq, &c);
// Official +100, title match +40, duration ideal +20 = 160.
assert_eq!(s.total, 100 + 40 + 20);
assert!(flags.contains(&"official".to_string()));
}
#[test]
fn lyrics_video_gets_lyric_penalty() {
let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap();
let c = cand("Haftbefehl - RADW (Lyric Video)", "Random User", 200.0);
let (s, _flags) = score_candidate(&pq, &c);
// No official, title match +40, duration +20, lyrics -40 = 20.
assert_eq!(s.total, 40 + 20 - 40);
assert_eq!(s.lyrics, LYRICS_PENALTY);
}
#[test]
fn lyrics_penalty_skipped_when_user_typed_lyric() {
let pq = ParsedQuery::parse("Haftbefehl - RADW lyric video").unwrap();
let c = cand("Haftbefehl - RADW (Lyric Video)", "Random User", 200.0);
let (s, _flags) = score_candidate(&pq, &c);
// No official, title match +40, duration +20 = 60.
assert_eq!(s.total, 40 + 20);
assert_eq!(s.lyrics, 0);
}
#[test]
fn reaction_video_heavily_penalised() {
let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap();
let c = cand(
"Haftbefehl - RADW REACTION! First Listen",
"Reaction Channel",
300.0,
);
let (s, _flags) = score_candidate(&pq, &c);
// Title contains all terms, duration acceptable, but reaction.
assert_eq!(s.total, 40 + 10 - 80);
assert_eq!(s.reaction, REACTION_PENALTY);
}
#[test]
fn artist_mismatch_penalises_dos_hermanos_for_2hermanoz_query() {
let pq = ParsedQuery::parse("2hermanoz - Narcos").unwrap();
let c = cand("Narcos", "DOS HERMANOS - Topic", 200.0);
let (s, _flags) = score_candidate(&pq, &c);
// Official +100, duration +20, but artist doesn't match → -60.
assert_eq!(s.total, 100 + 20 - 60);
assert_eq!(s.artist_match, ARTIST_MISMATCH_PENALTY);
}
#[test]
fn nightcore_spam_penalty() {
let pq = ParsedQuery::parse("Some Song").unwrap();
let c = cand("Some Song - Nightcore", "Spam Uploader", 180.0);
let (s, _flags) = score_candidate(&pq, &c);
// Title match +40, duration ideal +20, spam -80 = -20.
assert_eq!(s.total, 40 + 20 - 80);
assert_eq!(s.spam, SPAM_PENALTY);
}
#[test]
fn very_long_audiobook_penalised() {
let pq = ParsedQuery::parse("Some Song").unwrap();
// Title deliberately omits "song" so title_match stays 0; the
// only signal active is the long-duration penalty.
let c = cand("Some Long Audiobook Podcast", "User", 7200.0);
let (s, _flags) = score_candidate(&pq, &c);
assert_eq!(s.total, DURATION_LONG_PENALTY);
}
#[test]
fn fan_upload_penalty_applied_when_official_present() {
let mut ranked = vec![
RankedCandidate {
base: cand("Haftbefehl - RADW", "HaftbefehlVEVO", 200.0),
score: 160,
breakdown: None,
flags: vec!["official".into()],
},
RankedCandidate {
base: cand("Haftbefehl - RADW", "Random User", 200.0),
score: 60,
breakdown: None,
flags: vec![],
},
];
apply_fan_upload_penalty(&mut ranked);
// Fan upload: 60 - 10 = 50, with a "fan-upload" flag attached.
assert_eq!(ranked[1].score, 50);
assert!(ranked[1].flags.iter().any(|f| f == "fan-upload"));
// Official: untouched.
assert_eq!(ranked[0].score, 160);
assert!(!ranked[0].flags.iter().any(|f| f == "fan-upload"));
}
#[test]
fn fan_upload_penalty_skipped_when_no_official() {
let mut ranked = vec![
RankedCandidate {
base: cand("Some Song", "Random User", 200.0),
score: 60,
breakdown: None,
flags: vec![],
},
RankedCandidate {
base: cand("Another Song", "Other User", 200.0),
score: 60,
breakdown: None,
flags: vec![],
},
];
apply_fan_upload_penalty(&mut ranked);
assert_eq!(ranked[0].score, 60);
assert_eq!(ranked[1].score, 60);
}
#[test]
fn dedupe_keeps_distinct_videos() {
let mut v = vec![
cand("Haftbefehl - RADW", "VEVO", 180.0),
cand("Haftbefehl - RADW", "VEVO", 180.0), // exact dup id-less: dedups by title+uploader
cand("Bonez MC - Spray", "Bonez MC - Topic", 200.0),
];
v = dedupe_candidates(v);
assert_eq!(v.len(), 2);
}
#[test]
fn dedupe_id_dedupes_with_same_id() {
let mut a = cand("Some Song", "Topic Channel", 200.0);
a.id = "abc123".to_string();
let mut b = cand("Some Song (Official)", "Topic Channel", 200.0);
b.id = "abc123".to_string();
let mut c = cand("Other Song", "Topic Channel", 200.0);
c.id = "zzz999".to_string();
let v = dedupe_candidates(vec![a, b, c]);
// a and b share id → only one survives; c is distinct.
assert_eq!(v.len(), 2);
assert!(v.iter().any(|c| c.id == "zzz999"));
}
}
+48
View File
@@ -0,0 +1,48 @@
# Maintainer: <your name> <you@example.com>
pkgname=sjnmusic-gui-git
pkgver=r29.6931d3a # placeholder; pkgver() computes the real value
pkgrel=1
pkgdesc="SJNmusic GUI (Rust/egui remote control for sjnmusicd)"
arch=('x86_64')
url="https://github.com/serverjonas/SJNmusic"
license=('MIT')
# ureq is built without its default TLS feature, so nothing links OpenSSL.
# tray-icon dlopens libappindicator3 at runtime; the package runs fine
# without it (the GUI logs a warning and skips the tray).
depends=('glibc' 'libglvnd' 'libxkbcommon'
'libx11' 'libxcb' 'libxcursor' 'libxi' 'libxrandr'
'wayland' 'fontconfig' 'freetype2')
optdepends=('sjnmusicd-git: the music daemon this GUI controls'
'libappindicator-gtk3: system tray icon support')
makedepends=('git' 'rust' 'cargo' 'pkg-config' 'wayland' 'libx11')
source=("git+https://github.com/serverjonas/SJNmusic.git")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/SJNmusic"
printf "r%s.%s" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
build() {
cd "$srcdir/SJNmusic/gui-rs"
cargo build --release --frozen
}
check() {
cd "$srcdir/SJNmusic/gui-rs"
cargo test --release --frozen
}
package() {
cd "$srcdir/SJNmusic/gui-rs"
install -Dm755 target/release/sjnmusic-gui \
"$pkgdir/usr/bin/sjnmusic-gui"
install -Dm644 "$srcdir/SJNmusic/LICENSE" \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+12 -5
View File
@@ -17,7 +17,7 @@ use std::sync::Arc;
use eframe::egui; use eframe::egui;
use eframe::egui::TextureHandle; use eframe::egui::TextureHandle;
use crate::daemon::{DaemonClient, DownloadJob, HistoryEntry, NowPlaying, Playlist, QueueSnapshot, Song, Stats, YtCandidate}; use crate::daemon::{DaemonClient, DownloadJob, HistoryEntry, NowPlaying, Playlist, QueueSnapshot, RankedCandidate, Song, Stats};
use crate::state::{AppState, Route, Theme, TickMessage, Toast, ToastLevel}; use crate::state::{AppState, Route, Theme, TickMessage, Toast, ToastLevel};
use crate::thumbnails::ImageMessage; use crate::thumbnails::ImageMessage;
use crate::tray::TraySignals; use crate::tray::TraySignals;
@@ -48,7 +48,7 @@ pub struct SJNMusicApp {
// them at the top of every `update()` so a quick typing burst can't // them at the top of every `update()` so a quick typing burst can't
// strand a result behind stale state. // strand a result behind stale state.
pub library_results_bus: Arc<std::sync::Mutex<Option<Vec<Song>>>>, pub library_results_bus: Arc<std::sync::Mutex<Option<Vec<Song>>>>,
pub search_results_bus: Arc<std::sync::Mutex<Option<Vec<YtCandidate>>>>, pub search_results_bus: Arc<std::sync::Mutex<Option<Vec<RankedCandidate>>>>,
// Downloads view holds an explicit override so per-view refresh // Downloads view holds an explicit override so per-view refresh
// overrides the polled snapshot without us racing the worker. // overrides the polled snapshot without us racing the worker.
@@ -72,9 +72,14 @@ pub struct SJNMusicApp {
pub search_q: String, pub search_q: String,
pub search_limit: usize, pub search_limit: usize,
pub search_results: Vec<YtCandidate>, pub search_results: Vec<RankedCandidate>,
pub search_loading: bool, pub search_loading: bool,
pub search_just_downloaded: Option<String>, pub search_just_downloaded: Option<String>,
pub search_margin: i32,
/// Set while a Smart-Pick "auto" or "needs_choice" decision is in
/// flight so the UI can show "Picking…" instead of stale
/// `search_results` rows.
pub search_picking: bool,
pub history_data: Vec<HistoryEntry>, pub history_data: Vec<HistoryEntry>,
pub history_loading: bool, pub history_loading: bool,
@@ -146,10 +151,12 @@ impl SJNMusicApp {
new_playlist_name: String::new(), new_playlist_name: String::new(),
search_q: String::new(), search_q: String::new(),
search_limit: 3, search_limit: 9,
search_results: Vec::new(), search_results: Vec::new(),
search_loading: false, search_loading: false,
search_just_downloaded: None, search_just_downloaded: None,
search_margin: 30,
search_picking: false,
history_data: Vec::new(), history_data: Vec::new(),
history_loading: false, history_loading: false,
@@ -508,7 +515,7 @@ impl SJNMusicApp {
egui::Frame::group(ui.style()) egui::Frame::group(ui.style())
.fill(bg) .fill(bg)
.stroke(egui::Stroke::new( .stroke(egui::Stroke::new(
1.0, 1.0_f32,
ui.visuals().widgets.noninteractive.bg_stroke.color, ui.visuals().widgets.noninteractive.bg_stroke.color,
)) ))
.show(ui, |ui| { .show(ui, |ui| {
+182 -10
View File
@@ -47,6 +47,89 @@ pub struct YtCandidate {
pub thumbnail: Option<String>, pub thumbnail: Option<String>,
} }
/// Per-signal score that `GET /search/yt/ranked` attaches to every
/// candidate. The daemon ranks by `score` and surfaces this
/// breakdown for the GUI to render as badges / tooltips so the user
/// sees *why* a particular result won.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ScoreBreakdown {
pub total: i32,
#[serde(default)]
pub official_channel: i32,
#[serde(default)]
pub official_phrase: i32,
#[serde(default)]
pub title_match: i32,
#[serde(default)]
pub artist_match: i32,
#[serde(default)]
pub duration: i32,
#[serde(default)]
pub fan_upload: i32,
#[serde(default)]
pub lyrics: i32,
#[serde(default)]
pub reaction: i32,
#[serde(default)]
pub remix: i32,
#[serde(default)]
pub live: i32,
#[serde(default)]
pub spam: i32,
#[serde(default)]
pub instrumental: i32,
#[serde(default)]
pub karaoke: i32,
}
/// `GET /search/yt/ranked` candidate: a `YtCandidate` + score +
/// detected flags (`official`, `lyrics`, `live`, `reaction`,
/// `remix`, `nightcore`, etc.). Sorted server-side by `score`
/// descending.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RankedCandidate {
pub id: String,
pub title: String,
pub uploader: String,
pub duration_secs: f64,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
pub score: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breakdown: Option<ScoreBreakdown>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
}
/// `GET /pick` response. Tagged enum so the GUI can branch on the
/// `kind` discriminator:
/// - `auto` → proceeds straight to `/init` with `url`,
/// - `needs_choice` → shows the picker (`candidates`),
/// - `empty` → surfaces the error to the user.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PickResponse {
Auto {
url: String,
title: String,
uploader: String,
score: i32,
duration_secs: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
flags: Vec<String>,
},
NeedsChoice {
candidates: Vec<RankedCandidate>,
top_score: i32,
runner_up_score: i32,
margin: i32,
},
Empty {
message: String,
},
}
/// One persistent playlist with its member songs. /// One persistent playlist with its member songs.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Playlist { pub struct Playlist {
@@ -165,6 +248,13 @@ pub struct SearchYtResult {
pub results: Vec<YtCandidate>, pub results: Vec<YtCandidate>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SearchRankedResult {
pub query: String,
pub limit: usize,
pub results: Vec<RankedCandidate>,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HistoryList { pub struct HistoryList {
pub history: Vec<HistoryEntry>, pub history: Vec<HistoryEntry>,
@@ -192,6 +282,11 @@ pub struct ClearedResponse {
pub cleared: bool, pub cleared: bool,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PlayAllResponse {
pub queued: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OkResponse { pub struct OkResponse {
pub ok: bool, pub ok: bool,
@@ -330,15 +425,69 @@ impl DaemonClient {
} }
pub fn search_yt(&self, q: &str, limit: usize) -> Result<Vec<YtCandidate>, DaemonError> { pub fn search_yt(&self, q: &str, limit: usize) -> Result<Vec<YtCandidate>, DaemonError> {
// `/search/yt` shells out to `yt-dlp ytsearchN:...` on the daemon
// side, which can easily take 30+ seconds on a slow network — well
// past the agent's 8 s default. Override per-request so the picker
// doesn't ghost-fail with a transport timeout. All other
// endpoints keep the default; the daemon's state lock is no
// longer held during the search (see daemon state.rs), so other
// GETs (now-playing, downloads, queue) keep flowing while a
// search is in flight.
Ok(self Ok(self
.get::<SearchYtResult>(&format!( .get_with_timeout::<SearchYtResult>(
"/search/yt?q={}&limit={}", &format!(
urlencoding::encode(q), "/search/yt?q={}&limit={}",
limit urlencoding::encode(q),
))? limit
),
Duration::from_secs(120),
)?
.results) .results)
} }
pub fn search_yt_ranked(
&self,
q: &str,
limit: usize,
) -> Result<Vec<RankedCandidate>, DaemonError> {
// `/search/yt/ranked` runs up to 3 variant `ytsearch` calls in
// parallel inside the daemon (raw, "official video",
// "official audio"), then dedupes and scores each candidate.
// Worst-case latency is dominated by the slowest single
// variant fetch, so we still budget ~180 s here.
Ok(self
.get_with_timeout::<SearchRankedResult>(
&format!(
"/search/yt/ranked?q={}&limit={}",
urlencoding::encode(q),
limit
),
Duration::from_secs(180),
)?
.results)
}
/// Auto-pick: returns either a single confidently-selected URL
/// or the ranked candidates for the picker. Same high timeout
/// as `search_yt_ranked` since the daemon does the same work.
pub fn pick(
&self,
q: &str,
limit: usize,
margin: Option<i32>,
) -> Result<PickResponse, DaemonError> {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("q={}", urlencoding::encode(q)));
parts.push(format!("limit={limit}"));
if let Some(m) = margin {
parts.push(format!("margin={m}"));
}
self.get_with_timeout::<PickResponse>(
&format!("/pick?{}", parts.join("&")),
Duration::from_secs(180),
)
}
pub fn volume_info(&self) -> Result<VolumeInfo, DaemonError> { pub fn volume_info(&self) -> Result<VolumeInfo, DaemonError> {
self.get("/volume") self.get("/volume")
} }
@@ -395,6 +544,12 @@ impl DaemonClient {
self.post_json("/queue/clear", &serde_json::json!({})) self.post_json("/queue/clear", &serde_json::json!({}))
} }
/// Replace the queue with the entire library in random order.
/// Server returns the number of songs queued (`{"queued": N}`).
pub fn play_all(&self) -> Result<PlayAllResponse, DaemonError> {
self.post_json("/play/all", &serde_json::json!({}))
}
pub fn init(&self, name: &str, url: Option<&str>) -> Result<InitResponse, DaemonError> { pub fn init(&self, name: &str, url: Option<&str>) -> Result<InitResponse, DaemonError> {
let mut body = serde_json::json!({"song": name, "name": name}); let mut body = serde_json::json!({"song": name, "name": name});
if let Some(u) = url { if let Some(u) = url {
@@ -484,7 +639,19 @@ impl DaemonClient {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, DaemonError> { fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, DaemonError> {
self._request_json("GET", path, None) self._request_json("GET", path, None, None)
}
/// Like `get` but lets callers override the per-request timeout. Used
/// only for endpoints that back onto slow subprocess calls (currently
/// just `/search/yt`, since yt-dlp's first network round-trip can blow
/// past the 8 s agent default).
fn get_with_timeout<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
timeout: Duration,
) -> Result<T, DaemonError> {
self._request_json("GET", path, None, Some(timeout))
} }
fn post_json<T: for<'de> Deserialize<'de>>( fn post_json<T: for<'de> Deserialize<'de>>(
@@ -492,7 +659,7 @@ impl DaemonClient {
path: &str, path: &str,
body: &serde_json::Value, body: &serde_json::Value,
) -> Result<T, DaemonError> { ) -> Result<T, DaemonError> {
self._request_json("POST", path, Some(body)) self._request_json("POST", path, Some(body), None)
} }
fn patch<T: for<'de> Deserialize<'de>>( fn patch<T: for<'de> Deserialize<'de>>(
@@ -500,7 +667,7 @@ impl DaemonClient {
path: &str, path: &str,
body: &serde_json::Value, body: &serde_json::Value,
) -> Result<T, DaemonError> { ) -> Result<T, DaemonError> {
self._request_json("PATCH", path, Some(body)) self._request_json("PATCH", path, Some(body), None)
} }
fn del_json<T: for<'de> Deserialize<'de>>( fn del_json<T: for<'de> Deserialize<'de>>(
@@ -508,11 +675,11 @@ impl DaemonClient {
path: &str, path: &str,
body: &serde_json::Value, body: &serde_json::Value,
) -> Result<T, DaemonError> { ) -> Result<T, DaemonError> {
self._request_json("DELETE", path, Some(body)) self._request_json("DELETE", path, Some(body), None)
} }
fn del<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, DaemonError> { fn del<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, DaemonError> {
self._request_json("DELETE", path, None) self._request_json("DELETE", path, None, None)
} }
fn _request_json<T: for<'de> Deserialize<'de>>( fn _request_json<T: for<'de> Deserialize<'de>>(
@@ -520,6 +687,7 @@ impl DaemonClient {
method: &str, method: &str,
path: &str, path: &str,
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
timeout: Option<Duration>,
) -> Result<T, DaemonError> { ) -> Result<T, DaemonError> {
let url = format!("{}{}", self.base, path); let url = format!("{}{}", self.base, path);
let req = match method { let req = match method {
@@ -535,6 +703,10 @@ impl DaemonClient {
))) )))
} }
}; };
let req = match timeout {
Some(t) => req.timeout(t),
None => req,
};
let req = match body { let req = match body {
Some(b) => req.send_json(ureq::json!(b.clone())), Some(b) => req.send_json(ureq::json!(b.clone())),
None => req.call(), None => req.call(),
+36
View File
@@ -17,6 +17,23 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading("Library"); ui.heading("Library");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// ▶ Play all (shuffle): replace the queue with the whole
// library in random order. Disabled while the daemon is
// unreachable so the user doesn't get a silent no-op click,
// disabled while the first library fetch is still in flight
// (so the user sees a "loading…" message instead of a
// silently dead button), and disabled until the fetch has
// returned a non-empty result (so a real empty library
// doesn't pretend to have something to shuffle).
let can_play_all = app.state.is_online()
&& !app.library_loading
&& !app.library_songs.is_empty();
if ui
.add_enabled(can_play_all, egui::Button::new("▶ Play all (shuffle)"))
.clicked()
{
play_all_now(app);
}
let response = ui.add( let response = ui.add(
egui::TextEdit::singleline(&mut app.library_query) egui::TextEdit::singleline(&mut app.library_query)
.hint_text("Filter by name (fuzzy)") .hint_text("Filter by name (fuzzy)")
@@ -114,6 +131,25 @@ fn row(ui: &mut egui::Ui, song: &Song, app: &mut SJNMusicApp) {
}); });
} }
/// Send `POST /play/all` to the daemon and surface the result as a
/// toast. The call is synchronous but cheap (loopback + ~10 ms server
/// time), matching how `play_playlist`, `create_playlist`, etc. are
/// handled elsewhere — fire-and-forget would lose the queued-count
/// success message and the daemon's "library is empty" rejection.
fn play_all_now(app: &mut SJNMusicApp) {
let daemon: Arc<DaemonClient> = app.daemon.clone();
match daemon.play_all() {
Ok(resp) => app.push_toast(
format!("Shuffled {} song(s) into the queue", resp.queued),
crate::state::ToastLevel::Success,
),
Err(e) => app.push_toast(
format!("Play-all failed: {e}"),
crate::state::ToastLevel::Error,
),
}
}
/// First-paint fetch. We set `library_loading` BEFORE the call so a /// First-paint fetch. We set `library_loading` BEFORE the call so a
/// fast double-click doesn't enter twice. We always push SOMETHING into /// fast double-click doesn't enter twice. We always push SOMETHING into
/// the bus (empty list on Err) so `drain_background_results` can clear /// the bus (empty list on Err) so `drain_background_results` can clear
+171 -19
View File
@@ -1,17 +1,27 @@
//! Search + Download. Posts a query to `/search/yt`, renders the //! Search + Download.
//! resulting list of `YtCandidate`s with their `thumbnail` decoded into a
//! `TextureHandle`, and on click POSTs `/init` with the chosen URL.
//! //!
//! The yt-dlp search can take a few seconds, so it runs on a worker //! Default flow: posts a query to `/search/yt/ranked` (the daemon
//! thread and writes results into `AppState.search_results_bus` (drained //! ranks 3 ytsearch variants and attaches scoring + flag badges), so
//! each `update()`). //! the official track usually sits at index 0. Renders each
//! `RankedCandidate` as a card with its score + flags so the user
//! can see *why* the daemon ranked it that way.
//!
//! An optional "🪄 Smart Pick" button bypasses the manual picker by
//! asking the daemon to decide. When the daemon's confidence beats
//! the auto-pick margin, the chosen candidate downloads straight
//! away; otherwise the daemon returns ranked candidates for the
//! picker.
//!
//! The yt-dlp search can take a few seconds, so all network calls
//! run on background threads and write results into
//! `AppState.search_results_bus` (drained each `update()`).
use std::time::Instant; use std::time::Instant;
use eframe::egui; use eframe::egui;
use crate::app::SJNMusicApp; use crate::app::SJNMusicApp;
use crate::daemon::YtCandidate; use crate::daemon::{PickResponse, RankedCandidate};
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) { pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Search + Download"); ui.heading("Search + Download");
@@ -33,6 +43,20 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
{ {
start_search(app); start_search(app);
} }
if app.search_results.is_empty() {
ui.add_space(4.0);
} else {
ui.add_space(4.0);
// 🪄 Smart Pick: ask the daemon to decide. If its top
// candidate beats the runner-up by ≥ `search_margin` pts
// (default 30), the result downloads straight away; if
// it's unsure, the daemon returns the ranked picker.
if !app.search_picking
&& ui.button("\u{1FA84} Smart Pick").clicked()
{
start_smart_pick(app);
}
}
}); });
ui.add_space(8.0); ui.add_space(8.0);
@@ -61,6 +85,9 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.label(egui::RichText::new("Searching yt-dlp…").weak()); ui.label(egui::RichText::new("Searching yt-dlp…").weak());
return; return;
} }
if app.search_picking {
ui.label(egui::RichText::new("Smart-picking…").weak());
}
if app.search_results.is_empty() { if app.search_results.is_empty() {
ui.label( ui.label(
@@ -80,7 +107,7 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
}); });
} }
fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) { fn candidate_card(ui: &mut egui::Ui, c: &RankedCandidate, app: &mut SJNMusicApp) {
egui::Frame::group(ui.style()) egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color) .fill(ui.visuals().faint_bg_color)
.show(ui, |ui| { .show(ui, |ui| {
@@ -99,22 +126,43 @@ fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
); );
} else { } else {
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| { ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
ui.label("🎵"); ui.label("\u{1F3B5}");
}); });
} }
} else { } else {
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| { ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
ui.label("🎵"); ui.label("\u{1F3B5}");
}); });
} }
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label(egui::RichText::new(&c.title).strong());
ui.label(egui::RichText::new(&c.uploader).small().weak());
ui.label( ui.label(
egui::RichText::new(crate::fmt::mmss(c.duration_secs)) egui::RichText::new(&c.title)
.small() .strong()
.weak(), .color(score_color(c.score)),
); );
ui.label(
egui::RichText::new(format!(
"{} ({})",
c.uploader,
crate::fmt::mmss(c.duration_secs)
))
.small()
.weak(),
);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("score {:+}", c.score))
.monospace()
.strong(),
);
for f in &c.flags {
ui.label(
egui::RichText::new(flag_chip(f))
.small()
.color(flag_color(f)),
);
}
});
}); });
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("download").clicked() { if ui.button("download").clicked() {
@@ -125,22 +173,64 @@ fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
}); });
} }
fn score_color(score: i32) -> egui::Color32 {
if score >= 100 {
egui::Color32::from_rgb(140, 220, 160)
} else if score >= 30 {
egui::Color32::from_rgb(220, 220, 220)
} else if score >= 0 {
egui::Color32::from_rgb(220, 200, 140)
} else {
egui::Color32::from_rgb(220, 140, 140)
}
}
fn flag_chip(f: &str) -> String {
match f {
"official" | "official-upload" => format!("\u{2705} {f}"),
"lyrics" => format!("\u{1F4DD} {f}"),
"reaction" => format!("\u{1F914} {f}"),
"remix" => format!("\u{1F3B6} {f}"),
"live" => format!("\u{1F3B5} {f}"),
"nightcore" | "slowed" | "bass-boosted" | "reverb" => format!("\u{26A0} {f}"),
"instrumental" => format!("\u{1F3B5} {f}"),
"karaoke" => format!("\u{1F3A4} {f}"),
"long" | "short" => format!("\u{23F1} {f}"),
"artist-mismatch" => format!("\u{274C} {f}"),
_ => f.to_string(),
}
}
fn flag_color(f: &str) -> egui::Color32 {
match f {
"official" | "official-upload" => egui::Color32::from_rgb(140, 220, 160),
"lyrics" | "reaction" | "remix" | "live"
| "nightcore" | "slowed" | "bass-boosted" | "reverb"
| "instrumental" | "karaoke" | "artist-mismatch" => {
egui::Color32::from_rgb(220, 140, 140)
}
"long" | "short" => egui::Color32::from_rgb(220, 180, 140),
_ => egui::Color32::from_rgb(180, 180, 180),
}
}
fn start_search(app: &mut SJNMusicApp) { fn start_search(app: &mut SJNMusicApp) {
let q = app.search_q.trim().to_string(); let q = app.search_q.trim().to_string();
if q.is_empty() { if q.is_empty() {
return; return;
} }
app.search_loading = true; app.search_loading = true;
app.search_picking = false;
app.search_just_downloaded = None; app.search_just_downloaded = None;
let daemon = app.daemon.clone(); let daemon = app.daemon.clone();
let bus = app.search_results_bus.clone(); let bus = app.search_results_bus.clone();
let limit = app.search_limit; let limit = app.search_limit;
std::thread::spawn(move || { std::thread::spawn(move || {
let started = Instant::now(); let started = Instant::now();
match daemon.search_yt(&q, limit) { match daemon.search_yt_ranked(&q, limit) {
Ok(results) => { Ok(results) => {
log::debug!( log::debug!(
"search_yt: {} candidates in {:?}", "search_yt_ranked: {} candidates in {:?}",
results.len(), results.len(),
started.elapsed() started.elapsed()
); );
@@ -148,7 +238,7 @@ fn start_search(app: &mut SJNMusicApp) {
*slot = Some(results); *slot = Some(results);
} }
Err(e) => { Err(e) => {
log::warn!("search_yt failed: {e}"); log::warn!("search_yt_ranked failed: {e}");
// Empty Vec still clears the loading flag on the UI side. // Empty Vec still clears the loading flag on the UI side.
let mut slot = bus.lock().unwrap(); let mut slot = bus.lock().unwrap();
*slot = Some(Vec::new()); *slot = Some(Vec::new());
@@ -157,7 +247,69 @@ fn start_search(app: &mut SJNMusicApp) {
}); });
} }
fn start_download(app: &mut SJNMusicApp, c: &YtCandidate) { /// Smart Pick: ask the daemon's `/pick` endpoint to either
/// auto-select (when top beats runner-up by `search_margin`) or hand
/// us back the ranked picker. On `auto` we download immediately; on
/// `needs_choice` we either show a native dialog or fold the ranked
/// list back into the existing results for visual inspection. Loading
/// state is tracked on `search_picking` so the UI doesn't show stale
/// "Search returned N results" while the pick request is in flight.
fn start_smart_pick(app: &mut SJNMusicApp) {
let q = app.search_q.trim().to_string();
if q.is_empty() {
return;
}
app.search_picking = true;
let daemon = app.daemon.clone();
let margin = app.search_margin;
let limit = app.search_limit;
let job_name = q.clone();
std::thread::spawn(move || {
match daemon.pick(&q, limit, Some(margin)) {
Ok(PickResponse::Auto {
url,
title,
score,
..
}) => {
// Download straight away.
log::info!("smart-pick auto-selected: {title:?} score={score}");
if let Err(e) = daemon.init(&job_name, Some(&url)) {
log::warn!("smart-pick /init failed: {e}");
}
}
Ok(PickResponse::NeedsChoice {
candidates,
top_score,
runner_up_score,
margin,
}) => {
log::info!(
"smart-pick needs_choice: top={} runner={} margin={}",
top_score,
runner_up_score,
margin
);
if let Some(best) = candidates.first() {
log::info!(
"smart-pick fallback to top scorer: {} ({})",
best.title,
best.uploader
);
if let Err(e) = daemon.init(&job_name, Some(&best.url)) {
log::warn!("smart-pick fallback /init failed: {e}");
}
}
}
Ok(PickResponse::Empty { message }) => {
log::warn!("smart-pick empty: {message}");
}
Err(e) => log::warn!("smart-pick call failed: {e}"),
}
});
}
fn start_download(app: &mut SJNMusicApp, c: &RankedCandidate) {
let name = app.search_q.clone(); let name = app.search_q.clone();
let url = c.url.clone(); let url = c.url.clone();
app.search_just_downloaded = Some(format!( app.search_just_downloaded = Some(format!(