Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769cec8dbe | ||
|
|
63287fcc8a | ||
|
|
93be6a7f68 | ||
|
|
351bdb13f8 | ||
|
|
0b10095ebf | ||
|
|
e61c3456be | ||
|
|
7836add8bd | ||
|
|
e222d48179 | ||
|
|
1c75494c94 | ||
|
|
0c2638fec3 |
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
+347
-112
@@ -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)
|
|
||||||
# versus *prompt* the user. `--pick` overrides a non-TTY stdin so
|
|
||||||
# scripts can still drive the picker via a piped index if they want.
|
|
||||||
interactive = force_pick or (
|
|
||||||
sys.stdin.isatty() and not force_auto
|
|
||||||
)
|
|
||||||
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)
|
limit = getattr(args, "limit", None)
|
||||||
query = {"q": q}
|
|
||||||
if limit is not None and limit > 0:
|
# Drive the ranked pipeline (do_init returns the chosen
|
||||||
query["limit"] = str(limit)
|
# candidate as a JSON envelope so we don't need separate
|
||||||
status, payload = call(
|
# branches below for `/init`). Routing through `request`
|
||||||
host, port, "GET", "/search/yt", query=query
|
# 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,
|
||||||
)
|
)
|
||||||
obj = try_parse(payload)
|
chosen_obj = try_parse(envelope_payload) or {}
|
||||||
results = (obj or {}).get("results") or []
|
chosen = chosen_obj.get("chosen") or {}
|
||||||
if status != 200:
|
status, payload = request(
|
||||||
print_response(args, status, payload)
|
args, host, port, "POST", "/init",
|
||||||
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 ""},
|
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)
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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}");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}");
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-4
@@ -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,
|
||||||
|
|||||||
+179
-7
@@ -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>(
|
||||||
|
&format!(
|
||||||
"/search/yt?q={}&limit={}",
|
"/search/yt?q={}&limit={}",
|
||||||
urlencoding::encode(q),
|
urlencoding::encode(q),
|
||||||
limit
|
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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
.strong()
|
||||||
|
.color(score_color(c.score)),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"{} ({})",
|
||||||
|
c.uploader,
|
||||||
|
crate::fmt::mmss(c.duration_secs)
|
||||||
|
))
|
||||||
.small()
|
.small()
|
||||||
.weak(),
|
.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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user