mek sock to http server and add playlsit funktion
This commit is contained in:
+221
-23
@@ -1,36 +1,234 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sjnmusic - tiny CLI for sjnmusicd HTTP daemon.
|
||||||
|
Talks to the webserver at http://127.0.0.1:14567 by default. Override with
|
||||||
|
--host or --port, or set SJNMUSIC_HOST / SJNMUSIC_PORT environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import socket
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
SOCKET_PATH = "/tmp/sjnmusic.sock"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 14567
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
print("usage: sjnmusic <command> [value]")
|
p = argparse.ArgumentParser(
|
||||||
|
prog="sjnmusic",
|
||||||
|
description="Talk to the sjnmusicd HTTP daemon.",
|
||||||
|
)
|
||||||
|
p.add_argument("--host", default=os.environ.get("SJNMUSIC_HOST", DEFAULT_HOST))
|
||||||
|
p.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=int(os.environ.get("SJNMUSIC_PORT", str(DEFAULT_PORT))),
|
||||||
|
)
|
||||||
|
p.add_argument("--json", action="store_true", help="print raw JSON responses")
|
||||||
|
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
def add_search_fields(sp):
|
||||||
|
sp.add_argument("query", nargs="*", help="search text")
|
||||||
|
sp.add_argument("-q", "--query", dest="query_flag", help="search text")
|
||||||
|
|
||||||
|
add_search_fields(sub.add_parser("play", help="search & start playing immediately"))
|
||||||
|
add_search_fields(sub.add_parser("add", help="search & append to queue"))
|
||||||
|
add_search_fields(sub.add_parser("del", help="search & delete from disk+db"))
|
||||||
|
add_search_fields(sub.add_parser("init", help="download via yt-dlp and register"))
|
||||||
|
add_search_fields(sub.add_parser("search", help="search the song database"))
|
||||||
|
|
||||||
|
sub.add_parser("queue", help="show the current queue")
|
||||||
|
sub.add_parser("now", help="alias for /now-playing")
|
||||||
|
sub.add_parser("skip", help="skip the next song")
|
||||||
|
sub.add_parser("clear", help="empty the queue")
|
||||||
|
sub.add_parser("songs", help="list all known songs")
|
||||||
|
sub.add_parser("playlists", help="list all playlists")
|
||||||
|
sub.add_parser("help", help="list HTTP endpoints")
|
||||||
|
|
||||||
|
pl_new = sub.add_parser("pl-new", help="create a playlist")
|
||||||
|
pl_new.add_argument("name")
|
||||||
|
|
||||||
|
pl_show = sub.add_parser("pl-show", help="show playlist contents")
|
||||||
|
pl_show.add_argument("name")
|
||||||
|
|
||||||
|
pl_add = sub.add_parser("pl-add", help="add a song to a playlist")
|
||||||
|
pl_add.add_argument("name")
|
||||||
|
pl_add.add_argument("query", nargs="*")
|
||||||
|
pl_add.add_argument("-q", "--query", dest="query_flag")
|
||||||
|
|
||||||
|
pl_play = sub.add_parser("pl-play", help="load a playlist into the queue")
|
||||||
|
pl_play.add_argument("name")
|
||||||
|
|
||||||
|
pl_rm = sub.add_parser("pl-rm", help="delete a playlist")
|
||||||
|
pl_rm.add_argument("name")
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def join_query(args) -> str:
|
||||||
|
parts = []
|
||||||
|
if getattr(args, "query_flag", None):
|
||||||
|
parts.append(args.query_flag)
|
||||||
|
if getattr(args, "query", None):
|
||||||
|
parts.extend(args.query)
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def call(host, port, method, route, body=None, query=None):
|
||||||
|
qs = ""
|
||||||
|
if query:
|
||||||
|
qs = "?" + urllib.parse.urlencode(query)
|
||||||
|
url = f"http://{host}:{port}{route}{qs}"
|
||||||
|
|
||||||
|
data = None
|
||||||
|
headers = {}
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url=url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
payload = resp.read().decode("utf-8")
|
||||||
|
return resp.status, payload
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"error: cannot reach daemon at {url}: {e.reason}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def print_response(args, status, payload):
|
||||||
|
if args.json:
|
||||||
|
print(f"HTTP {status}\n{payload}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
obj = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"HTTP {status}: {payload}")
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict) and "error" in obj:
|
||||||
|
print(f"error: {obj['error']}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if "queue" in obj and "current" in obj:
|
||||||
cmd = sys.argv[1]
|
print("queue:")
|
||||||
value = " ".join(sys.argv[2:])
|
for s in obj.get("queue", []):
|
||||||
|
print(f" - [{s['id']}] {s['name']}")
|
||||||
if cmd == "play":
|
cur = obj.get("current")
|
||||||
payload = {"Play": value}
|
if cur:
|
||||||
elif cmd == "add":
|
print(f"now playing: [{cur['id']}] {cur['name']}")
|
||||||
payload = {"Add": value}
|
|
||||||
elif cmd == "del":
|
|
||||||
payload = {"Del": value}
|
|
||||||
elif cmd == "init":
|
|
||||||
payload = {"Init": value}
|
|
||||||
else:
|
else:
|
||||||
print(f"unknown command: {cmd}")
|
print("now playing: (idle)")
|
||||||
sys.exit(1)
|
return
|
||||||
|
if "current" in obj and "queue_len" in obj:
|
||||||
|
cur = obj.get("current")
|
||||||
|
if cur:
|
||||||
|
print(f"now playing: [{cur['id']}] {cur['name']}")
|
||||||
|
else:
|
||||||
|
print("now playing: (idle)")
|
||||||
|
print(f"queue length: {obj['queue_len']}")
|
||||||
|
return
|
||||||
|
if "songs" in obj:
|
||||||
|
for s in obj["songs"]:
|
||||||
|
print(f"[{s['id']}] {s['name']}")
|
||||||
|
return
|
||||||
|
if isinstance(obj, list):
|
||||||
|
if obj and isinstance(obj[0], dict) and obj[0].get("songs") is not None:
|
||||||
|
for pl in obj:
|
||||||
|
print(f"{pl['name']} ({len(pl.get('songs', []))} songs)")
|
||||||
|
for s in pl.get("songs", []):
|
||||||
|
print(f" - [{s['id']}] {s['name']}")
|
||||||
|
else:
|
||||||
|
for s in obj:
|
||||||
|
print(s)
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict) and "name" in obj and "songs" in obj:
|
||||||
|
pl = obj
|
||||||
|
print(f"playlist: {pl['name']}")
|
||||||
|
for s in pl.get("songs", []):
|
||||||
|
print(f" - [{s['id']}] {s['name']}")
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict) and "endpoints" in obj:
|
||||||
|
for ep in obj["endpoints"]:
|
||||||
|
print(ep)
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict) and "added" in obj:
|
||||||
|
print(f"added [{obj['added']['id']}] {obj['added']['name']} -> {obj['playlist']['name']}")
|
||||||
|
return
|
||||||
|
print(json.dumps(obj, indent=2))
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
sock.connect(SOCKET_PATH)
|
|
||||||
|
|
||||||
sock.sendall(json.dumps(payload).encode())
|
def main(argv=None):
|
||||||
sock.close()
|
args = build_parser().parse_args(argv)
|
||||||
|
host, port = args.host, args.port
|
||||||
|
q = join_query(args)
|
||||||
|
|
||||||
|
def with_q(body):
|
||||||
|
body = dict(body or {})
|
||||||
|
if q:
|
||||||
|
body.setdefault("query", q)
|
||||||
|
return body
|
||||||
|
|
||||||
|
if args.cmd == "play":
|
||||||
|
status, payload = call(host, port, "POST", "/play", body=with_q(None))
|
||||||
|
elif args.cmd == "add":
|
||||||
|
status, payload = call(host, port, "POST", "/add", body=with_q(None))
|
||||||
|
elif args.cmd == "del":
|
||||||
|
if not q:
|
||||||
|
print("error: 'del' requires a search query", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
status, payload = call(host, port, "POST", "/del", body=with_q(None))
|
||||||
|
elif args.cmd == "init":
|
||||||
|
if not q:
|
||||||
|
print("error: 'init' requires a song name", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
status, payload = call(host, port, "POST", "/init", body={"song": q})
|
||||||
|
elif args.cmd == "search":
|
||||||
|
if not q:
|
||||||
|
print("error: 'search' requires a query", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
status, payload = call(host, port, "GET", "/search", query={"q": q})
|
||||||
|
elif args.cmd == "queue":
|
||||||
|
status, payload = call(host, port, "GET", "/queue")
|
||||||
|
elif args.cmd == "now":
|
||||||
|
status, payload = call(host, port, "GET", "/now-playing")
|
||||||
|
elif args.cmd == "skip":
|
||||||
|
status, payload = call(host, port, "POST", "/skip")
|
||||||
|
elif args.cmd == "clear":
|
||||||
|
status, payload = call(host, port, "POST", "/queue/clear")
|
||||||
|
elif args.cmd == "songs":
|
||||||
|
status, payload = call(host, port, "GET", "/songs")
|
||||||
|
elif args.cmd == "playlists":
|
||||||
|
status, payload = call(host, port, "GET", "/playlists")
|
||||||
|
elif args.cmd == "help":
|
||||||
|
status, payload = call(host, port, "GET", "/help")
|
||||||
|
elif args.cmd == "pl-new":
|
||||||
|
status, payload = call(host, port, "POST", "/playlists", body={"name": args.name})
|
||||||
|
elif args.cmd == "pl-show":
|
||||||
|
status, payload = call(host, port, "GET", f"/playlists/{urllib.parse.quote(args.name)}")
|
||||||
|
elif args.cmd == "pl-add":
|
||||||
|
if not q:
|
||||||
|
print("error: 'pl-add' requires a song query", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
status, payload = call(
|
||||||
|
host, port, "POST",
|
||||||
|
f"/playlists/{urllib.parse.quote(args.name)}/add",
|
||||||
|
body={"query": q},
|
||||||
|
)
|
||||||
|
elif args.cmd == "pl-play":
|
||||||
|
status, payload = call(host, port, "POST", f"/playlists/{urllib.parse.quote(args.name)}/play")
|
||||||
|
elif args.cmd == "pl-rm":
|
||||||
|
status, payload = call(host, port, "DELETE", f"/playlists/{urllib.parse.quote(args.name)}")
|
||||||
|
else:
|
||||||
|
status, payload = call(host, port, "GET", "/help")
|
||||||
|
|
||||||
|
print_response(args, status, payload)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Generated
+320
-9
@@ -45,12 +45,68 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -144,6 +200,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clang-sys"
|
name = "clang-sys"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -161,6 +223,12 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
|
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -226,6 +294,38 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"defmt-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-macros"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
|
||||||
|
dependencies = [
|
||||||
|
"defmt-parser",
|
||||||
|
"proc-macro-error2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
@@ -262,6 +362,29 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_logger"
|
||||||
|
version = "0.11.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"env_filter",
|
||||||
|
"jiff",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -369,6 +492,12 @@ version = "3.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -379,6 +508,12 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -394,6 +529,31 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
"jiff-static",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -405,7 +565,7 @@ dependencies = [
|
|||||||
"combine",
|
"combine",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
@@ -550,7 +710,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -658,12 +818,24 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -676,13 +848,50 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.12+spec-1.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error-attr2"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-error-attr2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -717,7 +926,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -760,7 +969,7 @@ dependencies = [
|
|||||||
"hound",
|
"hound",
|
||||||
"lewton",
|
"lewton",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -841,6 +1050,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -858,11 +1076,16 @@ name = "sjnmusicd"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
"rodio",
|
"rodio",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"strsim",
|
"strsim",
|
||||||
|
"tiny_http",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -949,7 +1172,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -963,6 +1195,29 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_http"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"chunked_transfer",
|
||||||
|
"httpdate",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -978,6 +1233,27 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -987,6 +1263,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.12+spec-1.1.0"
|
version = "0.25.12+spec-1.1.0"
|
||||||
@@ -994,9 +1284,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 1.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1005,15 +1295,27 @@ version = "1.1.2+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 1.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -1365,6 +1667,15 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
strsim = "0.11"
|
strsim = "0.11"
|
||||||
|
tiny_http = "0.12"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
toml = "0.8"
|
||||||
|
percent-encoding = "2"
|
||||||
|
|||||||
+10
-5
@@ -1,3 +1,4 @@
|
|||||||
|
use log::debug;
|
||||||
use rodio::{Decoder, OutputStream, Sink};
|
use rodio::{Decoder, OutputStream, Sink};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
@@ -15,17 +16,21 @@ impl AudioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&mut self, path: &str) {
|
pub fn play(&mut self, path: &str) -> Result<(), String> {
|
||||||
let (_stream, handle) = OutputStream::try_default().unwrap();
|
let (_stream, handle) = OutputStream::try_default()
|
||||||
let sink = Sink::try_new(&handle).unwrap();
|
.map_err(|e| format!("could not open audio output: {e}"))?;
|
||||||
|
let sink = Sink::try_new(&handle).map_err(|e| format!("could not create sink: {e}"))?;
|
||||||
|
|
||||||
let file = File::open(path).unwrap();
|
let file = File::open(path).map_err(|e| format!("could not open file: {e}"))?;
|
||||||
let source = Decoder::new(BufReader::new(file)).unwrap();
|
let source =
|
||||||
|
Decoder::new(BufReader::new(file)).map_err(|e| format!("decode error: {e}"))?;
|
||||||
|
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
|
debug!("rodio sink appended for {path}");
|
||||||
|
|
||||||
self.sink = Some(sink);
|
self.sink = Some(sink);
|
||||||
self._stream = Some(_stream);
|
self._stream = Some(_stream);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_playing(&self) -> bool {
|
pub fn is_playing(&self) -> bool {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::paths::config_path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Bind host. Use "127.0.0.1" for local-only, "0.0.0.0" to expose on the network.
|
||||||
|
pub host: String,
|
||||||
|
/// Port to bind the HTTP server on.
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 14567,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoggingConfig {
|
||||||
|
/// Log filter: trace, debug, info, warn, error
|
||||||
|
pub level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level: "info".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub server: ServerConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub logging: LoggingConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// 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.
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = config_path();
|
||||||
|
|
||||||
|
if !std::path::Path::new(&path).exists() {
|
||||||
|
warn!("Config not found at {path}, creating default config");
|
||||||
|
let cfg = Config::default();
|
||||||
|
if let Ok(s) = toml::to_string_pretty(&cfg) {
|
||||||
|
if let Err(e) = fs::write(&path, s) {
|
||||||
|
warn!("Failed to write default config: {e}");
|
||||||
|
} else {
|
||||||
|
info!("Wrote default config to {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(text) => match toml::from_str::<Config>(&text) {
|
||||||
|
Ok(cfg) => {
|
||||||
|
debug!("Loaded config from {path}: {:?}", cfg);
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse config at {path}: {e}. Falling back to defaults.");
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to read config at {path}: {e}. Falling back to defaults.");
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-14
@@ -1,36 +1,99 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
state::DaemonState,
|
|
||||||
socket::start_socket,
|
|
||||||
db::init_db,
|
|
||||||
audio::AudioEngine,
|
audio::AudioEngine,
|
||||||
|
config::Config,
|
||||||
|
db::open_db,
|
||||||
|
server::start_server,
|
||||||
|
state::DaemonState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run(cfg: Config) {
|
||||||
let state = Arc::new(Mutex::new(DaemonState::new()));
|
info!("Starting sjnmusicd");
|
||||||
|
|
||||||
init_db();
|
let conn = open_db();
|
||||||
|
let state = Arc::new(Mutex::new(DaemonState::new(conn)));
|
||||||
|
|
||||||
let socket_state = state.clone();
|
// HTTP server thread.
|
||||||
thread::spawn(move || {
|
let server_state = state.clone();
|
||||||
start_socket(socket_state);
|
let host = cfg.server.host.clone();
|
||||||
|
let port = cfg.server.port;
|
||||||
|
let server_handle = thread::Builder::new()
|
||||||
|
.name("sjnmusic-http".into())
|
||||||
|
.spawn(move || {
|
||||||
|
start_server(server_state, &host, port);
|
||||||
});
|
});
|
||||||
|
if let Err(e) = server_handle {
|
||||||
|
error!("Failed to spawn HTTP server thread: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio playback loop (runs on the main thread).
|
||||||
let mut audio = AudioEngine::new();
|
let mut audio = AudioEngine::new();
|
||||||
|
info!("Playback loop ready");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
{
|
{
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
|
|
||||||
if !state.queue.is_empty() && !audio.is_playing() {
|
// Clear "now playing" once the audio engine reports the song has
|
||||||
let song = state.queue.remove(0);
|
// ended so /now-playing and /queue reflect reality while the
|
||||||
audio.play(&song.path);
|
// next iteration decides what's up next.
|
||||||
state.current = Some(song);
|
if !audio.is_playing() {
|
||||||
|
if let Some(prev) = state.current.take() {
|
||||||
|
debug!("Playback finished for {}", prev.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
if !state.queue.is_empty() && !audio.is_playing() {
|
||||||
|
// Snapshot the next song first, attempt audio.play, and only
|
||||||
|
// remove from the queue (memory + DB) when playback actually
|
||||||
|
// starts so decode/init failures don't silently lose it.
|
||||||
|
let next = state.queue[0].clone();
|
||||||
|
debug!("Starting playback: {} ({})", next.name, next.path);
|
||||||
|
match audio.play(&next.path) {
|
||||||
|
Ok(()) => match state.pop_played() {
|
||||||
|
Ok(Some(popped)) => {
|
||||||
|
let name = popped.name.clone();
|
||||||
|
state.current = Some(popped);
|
||||||
|
debug!("DB queue row removed for {name}");
|
||||||
|
info!("Now playing: {name}");
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// Should not happen — we peeked a non-empty queue.
|
||||||
|
state.current = Some(next.clone());
|
||||||
|
warn!(
|
||||||
|
"pop_played returned None after successful audio.play for {}",
|
||||||
|
next.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// pop_played is DB-first; on Err the in-memory
|
||||||
|
// queue has NOT been mutated, so the song is
|
||||||
|
// already at index 0 — do not re-insert or we
|
||||||
|
// would duplicate it.
|
||||||
|
error!(
|
||||||
|
"Played {} but failed to update DB queue ({e}); \
|
||||||
|
leaving in-memory queue untouched",
|
||||||
|
next.name
|
||||||
|
);
|
||||||
|
state.current = Some(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
// The previous "now playing" is no longer accurate.
|
||||||
|
state.current = None;
|
||||||
|
error!(
|
||||||
|
"Failed to play {} ({e}); leaving in queue",
|
||||||
|
next.path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(std::time::Duration::from_millis(200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-10
@@ -1,16 +1,54 @@
|
|||||||
|
use log::{debug, info};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::paths::db_path;
|
use crate::paths::{db_path, ensure_dirs};
|
||||||
|
|
||||||
pub fn init_db() {
|
/// Open (and configure) the SQLite database, creating tables on first run.
|
||||||
let conn = Connection::open(db_path()).unwrap();
|
pub fn open_db() -> Connection {
|
||||||
|
ensure_dirs();
|
||||||
|
let conn = Connection::open(db_path()).expect("Failed to open songs DB");
|
||||||
|
|
||||||
conn.execute(
|
// Reasonable defaults for a small local DB.
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA foreign_keys = ON;",
|
||||||
|
)
|
||||||
|
.expect("Failed to set DB pragmas");
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS songs (
|
"CREATE TABLE IF NOT EXISTS songs (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT UNIQUE,
|
name TEXT UNIQUE NOT NULL,
|
||||||
path TEXT
|
path TEXT NOT NULL
|
||||||
)",
|
);
|
||||||
[],
|
|
||||||
).unwrap();
|
CREATE TABLE IF NOT EXISTS queue (
|
||||||
|
position INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
song_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||||
|
playlist_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
song_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (playlist_id, position),
|
||||||
|
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playlist_songs_pl
|
||||||
|
ON playlist_songs(playlist_id, position);",
|
||||||
|
)
|
||||||
|
.expect("Failed to create DB schema");
|
||||||
|
|
||||||
|
debug!("Database initialized at {}", db_path());
|
||||||
|
info!("Database ready");
|
||||||
|
conn
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-6
@@ -1,11 +1,33 @@
|
|||||||
mod daemon;
|
|
||||||
mod socket;
|
|
||||||
mod protocol;
|
|
||||||
mod state;
|
|
||||||
mod db;
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod config;
|
||||||
|
mod daemon;
|
||||||
|
mod db;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
mod server;
|
||||||
|
mod state;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
daemon::run();
|
// Initialise logging first so subsequent startup steps are visible.
|
||||||
|
let cfg = config::Config::load();
|
||||||
|
env_logger::Builder::from_default_env()
|
||||||
|
.filter_level(parse_level(&cfg.logging.level))
|
||||||
|
.format_timestamp_secs()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
log::info!("Loaded configuration");
|
||||||
|
log::debug!("Config: {:?}", cfg);
|
||||||
|
|
||||||
|
paths::ensure_dirs();
|
||||||
|
daemon::run(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_level(s: &str) -> log::LevelFilter {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"trace" => log::LevelFilter::Trace,
|
||||||
|
"debug" => log::LevelFilter::Debug,
|
||||||
|
"info" => log::LevelFilter::Info,
|
||||||
|
"warn" | "warning" => log::LevelFilter::Warn,
|
||||||
|
"error" => log::LevelFilter::Error,
|
||||||
|
_ => log::LevelFilter::Info,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,14 @@ pub fn db_path() -> String {
|
|||||||
p.push("songs.db");
|
p.push("songs.db");
|
||||||
p.to_string_lossy().to_string()
|
p.to_string_lossy().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> String {
|
||||||
|
let mut p = base_dir();
|
||||||
|
p.push("config.toml");
|
||||||
|
p.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_dirs() {
|
||||||
|
let _ = std::fs::create_dir_all(base_dir());
|
||||||
|
let _ = std::fs::create_dir_all(songs_dir());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub enum Command {
|
|
||||||
Play(String),
|
|
||||||
Add(String),
|
|
||||||
Del(String),
|
|
||||||
Init(String),
|
|
||||||
Search(String),
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||||
|
|
||||||
|
use crate::state::DaemonState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ApiError {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response<T: Serialize>(status: u16, body: &T) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||||
|
let bytes = serde_json::to_vec(body).unwrap_or_else(|e| {
|
||||||
|
error!("JSON serialization failed: {e}");
|
||||||
|
br#"{"error":"serialization failed"}"#.to_vec()
|
||||||
|
});
|
||||||
|
Response::from_data(bytes)
|
||||||
|
.with_status_code(StatusCode(status))
|
||||||
|
.with_header(
|
||||||
|
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(msg: impl Into<String>) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||||
|
json_response(400, &ApiError { error: msg.into() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_body(body: &str) -> serde_json::Result<serde_json::Value> {
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
let server = match Server::http(&addr) {
|
||||||
|
Ok(s) => {
|
||||||
|
info!("HTTP server listening on http://{addr}");
|
||||||
|
info!("Set RUST_LOG=debug for verbose logs");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to bind HTTP server on {addr}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for request in server.incoming_requests() {
|
||||||
|
let state = state.clone();
|
||||||
|
let url = request.url().to_string();
|
||||||
|
let method = request.method().clone();
|
||||||
|
debug!("{} {}", method, url);
|
||||||
|
if let Err(e) = handle(request, state, &method, &url) {
|
||||||
|
warn!("Failed to respond: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the request so we can call `request.respond()` at the end. Reads the
|
||||||
|
/// body (for POST/PUT/DELETE), routes, and replies with JSON.
|
||||||
|
fn handle(
|
||||||
|
mut request: Request,
|
||||||
|
state: Arc<Mutex<DaemonState>>,
|
||||||
|
method: &Method,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = url.split('?').next().unwrap_or(url);
|
||||||
|
let route = route_for(method, path);
|
||||||
|
|
||||||
|
// /help short-circuit: no body, no state.
|
||||||
|
if matches!(route, Route::Help) {
|
||||||
|
let resp = json_response(200, &serde_json::json!({
|
||||||
|
"endpoints": [
|
||||||
|
"GET /help",
|
||||||
|
"GET /songs",
|
||||||
|
"GET /search?q=... or POST /search {query}",
|
||||||
|
"GET /queue",
|
||||||
|
"GET /now-playing",
|
||||||
|
"POST /play {query}",
|
||||||
|
"POST /add {query}",
|
||||||
|
"POST /del {query}",
|
||||||
|
"POST /init {song}",
|
||||||
|
"POST /skip",
|
||||||
|
"POST /queue/clear",
|
||||||
|
"GET /playlists",
|
||||||
|
"POST /playlists {name}",
|
||||||
|
"GET /playlists/{name}",
|
||||||
|
"POST /playlists/{name}/add {query}",
|
||||||
|
"POST /playlists/{name}/play",
|
||||||
|
"DELETE /playlists/{name}",
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
return Ok(request.respond(resp)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body_text = String::new();
|
||||||
|
if matches!(method, Method::Post | Method::Delete | Method::Put) {
|
||||||
|
let _ = request.as_reader().read_to_string(&mut body_text)?;
|
||||||
|
}
|
||||||
|
debug!("Body: {}", body_text);
|
||||||
|
|
||||||
|
let query_params = parse_query(url);
|
||||||
|
|
||||||
|
let mut state = match state.lock() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(p) => {
|
||||||
|
error!("State mutex poisoned: {p}");
|
||||||
|
let resp = error_response("internal state error");
|
||||||
|
return Ok(request.respond(resp)?);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match route {
|
||||||
|
Route::Help => unreachable!("handled above"),
|
||||||
|
|
||||||
|
Route::Songs => json_response(200, &serde_json::json!({
|
||||||
|
"songs": state.all_songs(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
Route::Search => match query_or_body(&query_params, &body_text) {
|
||||||
|
Ok(q) => match state.search(&q) {
|
||||||
|
Some(song) => json_response(200, &song),
|
||||||
|
None => json_response(404, &ApiError {
|
||||||
|
error: format!("song not found: {q}"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::Queue => json_response(200, &serde_json::json!({
|
||||||
|
"queue": state.queue,
|
||||||
|
"current": state.current,
|
||||||
|
})),
|
||||||
|
|
||||||
|
Route::NowPlaying => json_response(200, &serde_json::json!({
|
||||||
|
"current": state.current,
|
||||||
|
"queue_len": state.queue.len(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
Route::Play => match query_or_body(&query_params, &body_text) {
|
||||||
|
Ok(q) => match state.play(&q) {
|
||||||
|
Ok(song) => json_response(200, &song),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::Add => match query_or_body(&query_params, &body_text) {
|
||||||
|
Ok(q) => match state.add(&q) {
|
||||||
|
Ok(song) => json_response(200, &song),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::Del => match query_or_body(&query_params, &body_text) {
|
||||||
|
Ok(q) => match state.delete(&q) {
|
||||||
|
Ok(song) => json_response(200, &song),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::Init => match name_from_body(&body_text) {
|
||||||
|
Ok(song) => match state.init(song) {
|
||||||
|
Ok(s) => json_response(200, &s),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::Skip => match state.skip() {
|
||||||
|
Ok(Some(s)) => json_response(200, &s),
|
||||||
|
Ok(None) => json_response(200, &serde_json::json!({"skipped": null})),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::QueueClear => match state.clear_queue() {
|
||||||
|
Ok(()) => json_response(200, &serde_json::json!({"cleared": true})),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::PlaylistsList => json_response(200, &state.list_playlists()),
|
||||||
|
|
||||||
|
Route::PlaylistCreate => match name_from_body(&body_text) {
|
||||||
|
Ok(name) => match state.create_playlist(&name) {
|
||||||
|
Ok(pl) => json_response(200, &pl),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::PlaylistGet(name) => match state.get_playlist(&name) {
|
||||||
|
Ok(pl) => json_response(200, &pl),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::PlaylistAdd(name) => match query_or_body(&query_params, &body_text) {
|
||||||
|
Ok(q) => match state.add_to_playlist(&name, &q) {
|
||||||
|
Ok((pl, song)) => json_response(200, &serde_json::json!({
|
||||||
|
"playlist": pl,
|
||||||
|
"added": song,
|
||||||
|
})),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::PlaylistPlay(name) => match state.play_playlist(&name) {
|
||||||
|
Ok(pl) => json_response(200, &pl),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::PlaylistDelete(name) => match state.delete_playlist(&name) {
|
||||||
|
Ok(()) => json_response(200, &serde_json::json!({"deleted": name})),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
|
Route::NotFound => json_response(404, &ApiError {
|
||||||
|
error: format!("no route for {method} {path}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(request.respond(response)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Route {
|
||||||
|
Help,
|
||||||
|
Songs,
|
||||||
|
Search,
|
||||||
|
Queue,
|
||||||
|
NowPlaying,
|
||||||
|
Play,
|
||||||
|
Add,
|
||||||
|
Del,
|
||||||
|
Init,
|
||||||
|
Skip,
|
||||||
|
QueueClear,
|
||||||
|
PlaylistsList,
|
||||||
|
PlaylistCreate,
|
||||||
|
PlaylistGet(String),
|
||||||
|
PlaylistAdd(String),
|
||||||
|
PlaylistPlay(String),
|
||||||
|
PlaylistDelete(String),
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route_for(method: &Method, path: &str) -> Route {
|
||||||
|
let p = path.trim_end_matches('/');
|
||||||
|
match (method, p) {
|
||||||
|
(Method::Get, "") | (Method::Get, "/help") => Route::Help,
|
||||||
|
(Method::Get, "/songs") => Route::Songs,
|
||||||
|
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
|
||||||
|
(Method::Get, "/queue") => Route::Queue,
|
||||||
|
(Method::Get, "/now-playing") => Route::NowPlaying,
|
||||||
|
(Method::Post, "/play") => Route::Play,
|
||||||
|
(Method::Post, "/add") => Route::Add,
|
||||||
|
(Method::Post, "/del") => Route::Del,
|
||||||
|
(Method::Post, "/init") => Route::Init,
|
||||||
|
(Method::Post, "/skip") => Route::Skip,
|
||||||
|
(Method::Post, "/queue/clear") => Route::QueueClear,
|
||||||
|
(Method::Get, "/playlists") => Route::PlaylistsList,
|
||||||
|
(Method::Post, "/playlists") => Route::PlaylistCreate,
|
||||||
|
(Method::Get, path) if path.starts_with("/playlists/") => {
|
||||||
|
let rest = &path["/playlists/".len()..];
|
||||||
|
let (name, suffix) = split_playlist_route(rest);
|
||||||
|
match suffix {
|
||||||
|
"/add" => Route::PlaylistAdd(name),
|
||||||
|
"/play" => Route::PlaylistPlay(name),
|
||||||
|
"" => Route::PlaylistGet(name),
|
||||||
|
_ => Route::NotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Method::Delete, path) if path.starts_with("/playlists/") => {
|
||||||
|
let name = path["/playlists/".len()..].to_string();
|
||||||
|
Route::PlaylistDelete(name)
|
||||||
|
}
|
||||||
|
_ => Route::NotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_playlist_route(rest: &str) -> (String, &'static str) {
|
||||||
|
if let Some(name) = rest.strip_suffix("/add") {
|
||||||
|
return (name.to_string(), "/add");
|
||||||
|
}
|
||||||
|
if let Some(name) = rest.strip_suffix("/play") {
|
||||||
|
return (name.to_string(), "/play");
|
||||||
|
}
|
||||||
|
(rest.to_string(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_query(url: &str) -> std::collections::HashMap<String, String> {
|
||||||
|
let mut out = std::collections::HashMap::new();
|
||||||
|
if let Some(q) = url.split('?').nth(1) {
|
||||||
|
for pair in q.split('&') {
|
||||||
|
if let Some((k, v)) = pair.split_once('=') {
|
||||||
|
out.insert(urldecode(k), urldecode(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-decodes using `url` crate's `percent_encoding` so that UTF-8
|
||||||
|
/// sequences survive intact.
|
||||||
|
fn urldecode(s: &str) -> String {
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
percent_decode_str(s)
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.into_owned()
|
||||||
|
.replace('+', " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_or_body(
|
||||||
|
query_params: &std::collections::HashMap<String, String>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if let Some(q) = query_params.get("q") {
|
||||||
|
if !q.is_empty() {
|
||||||
|
return Ok(q.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
|
||||||
|
if let Some(obj) = v.as_object() {
|
||||||
|
for key in ["query", "name", "song", "q"] {
|
||||||
|
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
|
||||||
|
if !s.is_empty() {
|
||||||
|
return Ok(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("missing 'query' parameter".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_from_body(body: &str) -> Result<String, String> {
|
||||||
|
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
|
||||||
|
if let Some(obj) = v.as_object() {
|
||||||
|
for key in ["name", "song", "query"] {
|
||||||
|
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
|
||||||
|
if !s.is_empty() {
|
||||||
|
return Ok(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("missing 'name' or 'song' in body".to_string())
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use std::io::{Read, Write};
|
|
||||||
use std::os::unix::net::UnixListener;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use crate::{protocol::Command, state::DaemonState};
|
|
||||||
|
|
||||||
pub fn start_socket(state: Arc<Mutex<DaemonState>>) {
|
|
||||||
let _ = std::fs::remove_file("/tmp/sjnmusic.sock");
|
|
||||||
|
|
||||||
let listener = UnixListener::bind("/tmp/sjnmusic.sock").unwrap();
|
|
||||||
|
|
||||||
for stream in listener.incoming() {
|
|
||||||
let state = state.clone();
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
let mut stream = stream.unwrap();
|
|
||||||
|
|
||||||
let mut buf = [0; 2048];
|
|
||||||
let size = stream.read(&mut buf).unwrap();
|
|
||||||
|
|
||||||
let msg = String::from_utf8_lossy(&buf[..size]);
|
|
||||||
|
|
||||||
if let Ok(cmd) = serde_json::from_str::<Command>(&msg) {
|
|
||||||
let response = handle(cmd, state);
|
|
||||||
let _ = stream.write_all(response.as_bytes());
|
|
||||||
} else {
|
|
||||||
let _ = stream.write_all(b"ERROR: Invalid JSON");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle(cmd: Command, state: Arc<Mutex<DaemonState>>) -> String {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
|
|
||||||
match cmd {
|
|
||||||
Command::Play(query) => {
|
|
||||||
state.play(&query);
|
|
||||||
format!("OK: Playing {}", query)
|
|
||||||
}
|
|
||||||
Command::Add(query) => {
|
|
||||||
state.add(&query);
|
|
||||||
format!("OK: Added {}", query)
|
|
||||||
}
|
|
||||||
Command::Del(query) => {
|
|
||||||
state.delete(&query);
|
|
||||||
format!("OK: Deleted {}", query)
|
|
||||||
}
|
|
||||||
Command::Init(song) => {
|
|
||||||
state.init(song.clone());
|
|
||||||
format!("OK: Downloading {}", song)
|
|
||||||
}
|
|
||||||
Command::Search(query) => {
|
|
||||||
if let Some(song) = state.search(&query) {
|
|
||||||
serde_json::to_string(&song).unwrap_or_else(|_| "ERROR: Serialization failed".to_string())
|
|
||||||
} else {
|
|
||||||
"ERROR: Song not found".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+442
-88
@@ -1,139 +1,493 @@
|
|||||||
use std::process::Command as SysCommand;
|
use std::sync::Mutex;
|
||||||
use strsim::jaro_winkler;
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
use log::{debug, error, info, warn};
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use strsim::jaro_winkler;
|
||||||
|
|
||||||
|
use crate::paths::song_path;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Song {
|
pub struct Song {
|
||||||
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub songs: Vec<Song>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-memory mirror of the SQLite-backed state. Wrapped in a Mutex by
|
||||||
|
/// the daemon so HTTP request handlers and the playback loop can share it.
|
||||||
pub struct DaemonState {
|
pub struct DaemonState {
|
||||||
|
pub conn: Mutex<Connection>,
|
||||||
|
/// Songs pending play, in order. The first element is the next to play.
|
||||||
pub queue: Vec<Song>,
|
pub queue: Vec<Song>,
|
||||||
|
/// Currently playing song, if any.
|
||||||
pub current: Option<Song>,
|
pub current: Option<Song>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FUZZY_THRESHOLD: f64 = 0.65;
|
||||||
|
|
||||||
impl DaemonState {
|
impl DaemonState {
|
||||||
pub fn new() -> Self {
|
pub fn new(conn: Connection) -> Self {
|
||||||
|
let queue = Self::load_queue_from_db(&conn);
|
||||||
|
info!("Restored queue with {} song(s) from DB", queue.len());
|
||||||
Self {
|
Self {
|
||||||
queue: Vec::new(),
|
conn: Mutex::new(conn),
|
||||||
|
queue,
|
||||||
current: None,
|
current: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&mut self, query: &str) {
|
fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||||
if let Some(song) = self.search(query) {
|
match self.conn.lock() {
|
||||||
self.queue.insert(0, song);
|
Ok(g) => g,
|
||||||
} else {
|
Err(p) => {
|
||||||
eprintln!("Song nicht gefunden: {}", query);
|
warn!("DB mutex was poisoned, recovering");
|
||||||
|
p.into_inner()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add(&mut self, query: &str) {
|
// ------------------------------------------------------------------
|
||||||
if let Some(song) = self.search(query) {
|
// DB -> memory helpers
|
||||||
self.queue.push(song);
|
// ------------------------------------------------------------------
|
||||||
} else {
|
|
||||||
eprintln!("Song nicht gefunden: {}", query);
|
fn load_queue_from_db(conn: &Connection) -> Vec<Song> {
|
||||||
|
let mut stmt = match conn.prepare(
|
||||||
|
"SELECT s.id, s.name, s.path
|
||||||
|
FROM queue q JOIN songs s ON s.id = q.song_id
|
||||||
|
ORDER BY q.position ASC",
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to prepare queue load: {e}");
|
||||||
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.query_map([], |row| {
|
||||||
|
Ok(Song {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|it| it.filter_map(Result::ok).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&mut self, query: &str) {
|
/// Public wrapper around `all_songs`: returns every song in the DB, sorted
|
||||||
if let Some(song) = self.search(query) {
|
/// by name. Used by `DaemonState::search` (in-memory) and the `/songs`
|
||||||
let _ = std::fs::remove_file(&song.path);
|
/// HTTP route.
|
||||||
self.queue.retain(|s| s.name != song.name);
|
pub fn all_songs(&self) -> Vec<Song> {
|
||||||
} else {
|
Self::list_songs(&self.lock_conn())
|
||||||
eprintln!("Song nicht gefunden: {}", query);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&mut self, name: String) {
|
fn list_songs(conn: &Connection) -> Vec<Song> {
|
||||||
let path = crate::paths::song_path(&name);
|
let mut stmt = match conn.prepare("SELECT id, name, path FROM songs ORDER BY name") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to list songs: {e}");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let result = SysCommand::new("yt-dlp")
|
stmt.query_map([], |row| {
|
||||||
|
Ok(Song {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|it| it.filter_map(Result::ok).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_song(conn: &Connection, name: &str, path: &str) -> rusqlite::Result<i64> {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO songs (name, path) VALUES (?1, ?2)",
|
||||||
|
params![name, path],
|
||||||
|
)?;
|
||||||
|
let id: i64 = conn.query_row(
|
||||||
|
"SELECT id FROM songs WHERE name = ?1",
|
||||||
|
params![name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_append_db(conn: &Connection, song_id: i64) -> rusqlite::Result<()> {
|
||||||
|
// Append at the end. Explicit max+1 so ordering survives deletes.
|
||||||
|
let next: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(MAX(position), 0) + 1 FROM queue",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(1);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO queue (position, song_id) VALUES (?1, ?2)",
|
||||||
|
params![next, song_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_clear_db(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.execute("DELETE FROM queue", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playlist_append_db(
|
||||||
|
conn: &Connection,
|
||||||
|
playlist_id: i64,
|
||||||
|
song_id: i64,
|
||||||
|
) -> rusqlite::Result<()> {
|
||||||
|
let next: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(MAX(position), 0) + 1 FROM playlist_songs WHERE playlist_id = ?1",
|
||||||
|
params![playlist_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(1);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO playlist_songs (playlist_id, position, song_id) VALUES (?1, ?2, ?3)",
|
||||||
|
params![playlist_id, next, song_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Public API used by both HTTP server and playback loop
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Search the songs DB by name. Uses an exact (case-insensitive) match
|
||||||
|
/// first, then falls back to a Jaro-Winkler fuzzy match.
|
||||||
|
pub fn search(&self, query: &str) -> Option<Song> {
|
||||||
|
debug!("Searching DB for: {query}");
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let songs = self.all_songs();
|
||||||
|
|
||||||
|
// Exact match wins outright.
|
||||||
|
if let Some(s) = songs.iter().find(|s| s.name.to_lowercase() == query_lower) {
|
||||||
|
debug!("Exact DB match: {} (id={})", s.name, s.id);
|
||||||
|
return Some(s.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best fuzzy match above threshold.
|
||||||
|
let best = songs
|
||||||
|
.iter()
|
||||||
|
.map(|s| (s.clone(), jaro_winkler(&s.name.to_lowercase(), &query_lower)))
|
||||||
|
.filter(|(_, score)| *score > FUZZY_THRESHOLD)
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
if let Some((song, score)) = best {
|
||||||
|
debug!("Fuzzy DB match: {} (score={:.3})", song.name, score);
|
||||||
|
return Some(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!("No DB match for query: {query}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a song at the front of the queue and start playing it.
|
||||||
|
pub fn play(&mut self, query: &str) -> Result<Song, String> {
|
||||||
|
let song = self
|
||||||
|
.search(query)
|
||||||
|
.ok_or_else(|| format!("song not found: {query}"))?;
|
||||||
|
self.queue.insert(0, song.clone());
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
// Reset persisted queue to keep DB and memory consistent.
|
||||||
|
Self::queue_clear_db(&conn).map_err(|e| e.to_string())?;
|
||||||
|
for s in &self.queue {
|
||||||
|
Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
info!("Play: {} (id={})", song.name, song.id);
|
||||||
|
Ok(song)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a song to the end of the queue.
|
||||||
|
pub fn add(&mut self, query: &str) -> Result<Song, String> {
|
||||||
|
let song = self
|
||||||
|
.search(query)
|
||||||
|
.ok_or_else(|| format!("song not found: {query}"))?;
|
||||||
|
self.queue.push(song.clone());
|
||||||
|
Self::queue_append_db(&self.lock_conn(), song.id).map_err(|e| e.to_string())?;
|
||||||
|
info!("Added to queue: {} (id={})", song.name, song.id);
|
||||||
|
Ok(song)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a song from the DB, filesystem, and any queue entries.
|
||||||
|
pub fn delete(&mut self, query: &str) -> Result<Song, String> {
|
||||||
|
let song = self
|
||||||
|
.search(query)
|
||||||
|
.ok_or_else(|| format!("song not found: {query}"))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
// Foreign keys remove queue & playlist entries automatically.
|
||||||
|
conn.execute("DELETE FROM songs WHERE id = ?1", params![song.id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::remove_file(&song.path) {
|
||||||
|
warn!("Could not delete file {}: {e}", song.path);
|
||||||
|
}
|
||||||
|
self.queue.retain(|s| s.id != song.id);
|
||||||
|
if self
|
||||||
|
.current
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.id == song.id)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
self.current = None;
|
||||||
|
}
|
||||||
|
info!("Deleted song {} (id={})", song.name, song.id);
|
||||||
|
Ok(song)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a song via yt-dlp and register it in the DB.
|
||||||
|
pub fn init(&self, name: String) -> Result<Song, String> {
|
||||||
|
let path = song_path(&name);
|
||||||
|
debug!("yt-dlp target path: {path}");
|
||||||
|
|
||||||
|
let output = std::process::Command::new("yt-dlp")
|
||||||
.args([
|
.args([
|
||||||
"-x",
|
"-x",
|
||||||
"--audio-format",
|
"--audio-format",
|
||||||
"mp3",
|
"mp3",
|
||||||
"-o",
|
"-o",
|
||||||
&path,
|
&path,
|
||||||
&format!("ytsearch1:{}", name),
|
&format!("ytsearch1:{name}"),
|
||||||
])
|
])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
if result.is_err() {
|
match output {
|
||||||
eprintln!("INIT failed: yt-dlp not available or download error");
|
Ok(out) => {
|
||||||
return;
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
error!("yt-dlp failed for {name}: {stderr}");
|
||||||
|
return Err(format!("yt-dlp failed: {stderr}"));
|
||||||
|
}
|
||||||
|
info!("yt-dlp finished for {name}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("yt-dlp not available: {e}");
|
||||||
|
return Err(format!("yt-dlp unavailable: {e}"));
|
||||||
}
|
}
|
||||||
println!("INIT finished: {}", path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search(&self, query: &str) -> Option<Song> {
|
if !std::path::Path::new(&path).exists() {
|
||||||
use std::fs;
|
return Err(format!("download finished but file missing: {path}"));
|
||||||
|
}
|
||||||
|
|
||||||
let songs_dir = crate::paths::songs_dir();
|
let conn = self.lock_conn();
|
||||||
let query_lower = query.to_lowercase();
|
let id = Self::insert_song(&conn, &name, &path).map_err(|e| e.to_string())?;
|
||||||
|
info!("Registered song {name} (id={id}) at {path}");
|
||||||
|
Ok(Song {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Alle .mp3 Dateien aus songs_dir lesen
|
/// Clear the queue (memory + DB).
|
||||||
let entries = match fs::read_dir(&songs_dir) {
|
pub fn clear_queue(&mut self) -> Result<(), String> {
|
||||||
Ok(entries) => entries,
|
self.queue.clear();
|
||||||
Err(_) => return None,
|
Self::queue_clear_db(&self.lock_conn()).map_err(|e| e.to_string())?;
|
||||||
|
info!("Queue cleared");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the song at the head of the queue as having been played. Removes
|
||||||
|
/// the same row from the in-memory queue and the `queue` table so that
|
||||||
|
/// a daemon restart does not put already-played songs back into rotation.
|
||||||
|
/// The DB is updated first — if the DELETE fails the in-memory queue
|
||||||
|
/// is left untouched and the error is returned to the caller, keeping
|
||||||
|
/// memory and DB consistent.
|
||||||
|
pub fn pop_played(&mut self) -> Result<Option<Song>, String> {
|
||||||
|
if self.queue.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let id = self.queue[0].id;
|
||||||
|
// Scope the DB guard so it is dropped before mutating `self.queue`.
|
||||||
|
let result = {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM queue WHERE song_id = ?1 ORDER BY position ASC LIMIT 1",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
result.map_err(|e| format!("failed to delete queue row from DB: {e}"))?;
|
||||||
|
Ok(Some(self.queue.remove(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip the song currently at the front of the queue. Thin wrapper around
|
||||||
|
/// `pop_played` so the queue-pop SQL lives in exactly one place.
|
||||||
|
pub fn skip(&mut self) -> Result<Option<Song>, String> {
|
||||||
|
if let Some(skipped) = self.pop_played()? {
|
||||||
|
info!("Skipped {}", skipped.name);
|
||||||
|
Ok(Some(skipped))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Playlists
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn list_playlists(&self) -> Vec<Playlist> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
let mut stmt = match conn.prepare("SELECT id, name FROM playlists ORDER BY name") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to list playlists: {e}");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut best_match: Option<(String, f64)> = None;
|
let ids: Vec<(i64, String)> = stmt
|
||||||
const MIN_SCORE: f64 = 0.65; // Threshold - nicht zu aggressiv
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.map(|it| it.filter_map(Result::ok).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
drop(stmt);
|
||||||
|
|
||||||
for entry in entries {
|
ids.into_iter()
|
||||||
if let Ok(entry) = entry {
|
.map(|(id, name)| {
|
||||||
if let Ok(metadata) = entry.metadata() {
|
let songs = Self::load_playlist_songs(&conn, id);
|
||||||
if metadata.is_file() {
|
Playlist { id, name, songs }
|
||||||
if let Some(filename) = entry.file_name().to_str() {
|
})
|
||||||
// .mp3 entfernen
|
.collect()
|
||||||
let song_name = filename.strip_suffix(".mp3")
|
|
||||||
.or_else(|| filename.strip_suffix(".webm"))
|
|
||||||
.unwrap_or(filename);
|
|
||||||
|
|
||||||
let song_name_lower = song_name.to_lowercase();
|
|
||||||
|
|
||||||
// Exakte Übereinstimmung (case-insensitive)
|
|
||||||
if song_name_lower == query_lower {
|
|
||||||
return Some(Song {
|
|
||||||
name: song_name.to_string(),
|
|
||||||
path: entry.path().to_string_lossy().to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy match mit Jaro-Winkler
|
fn load_playlist_songs(conn: &Connection, playlist_id: i64) -> Vec<Song> {
|
||||||
let score = jaro_winkler(&song_name_lower, &query_lower);
|
let mut stmt = match conn.prepare(
|
||||||
|
"SELECT s.id, s.name, s.path
|
||||||
if score > MIN_SCORE {
|
FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id
|
||||||
if best_match.is_none() || score > best_match.as_ref().unwrap().1 {
|
WHERE ps.playlist_id = ?1
|
||||||
best_match = Some((
|
ORDER BY ps.position ASC",
|
||||||
entry.path().to_string_lossy().to_string(),
|
) {
|
||||||
score
|
Ok(s) => s,
|
||||||
));
|
Err(_) => return Vec::new(),
|
||||||
}
|
};
|
||||||
}
|
stmt.query_map(params![playlist_id], |row| {
|
||||||
}
|
Ok(Song {
|
||||||
}
|
id: row.get(0)?,
|
||||||
}
|
name: row.get(1)?,
|
||||||
}
|
path: row.get(2)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|it| it.filter_map(Result::ok).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn fuzzy match gefunden, returne es
|
pub fn create_playlist(&self, name: &str) -> Result<Playlist, String> {
|
||||||
if let Some((path, _)) = best_match {
|
let conn = self.lock_conn();
|
||||||
if let Some(filename) = std::path::Path::new(&path)
|
conn.execute(
|
||||||
.file_name()
|
"INSERT OR IGNORE INTO playlists (name) VALUES (?1)",
|
||||||
.and_then(|f| f.to_str()) {
|
params![name],
|
||||||
let song_name = filename.strip_suffix(".mp3")
|
)
|
||||||
.or_else(|| filename.strip_suffix(".webm"))
|
.map_err(|e| e.to_string())?;
|
||||||
.unwrap_or(filename);
|
let id: i64 = conn
|
||||||
return Some(Song {
|
.query_row(
|
||||||
name: song_name.to_string(),
|
"SELECT id FROM playlists WHERE name = ?1",
|
||||||
path,
|
params![name],
|
||||||
});
|
|row| row.get(0),
|
||||||
}
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
info!("Created playlist {name} (id={id})");
|
||||||
|
Ok(Playlist {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
songs: Vec::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
pub fn delete_playlist(&self, name: &str) -> Result<(), String> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
let affected = conn
|
||||||
|
.execute("DELETE FROM playlists WHERE name = ?1", params![name])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if affected == 0 {
|
||||||
|
return Err(format!("playlist not found: {name}"));
|
||||||
|
}
|
||||||
|
info!("Deleted playlist {name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_to_playlist(
|
||||||
|
&self,
|
||||||
|
playlist: &str,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<(Playlist, Song), String> {
|
||||||
|
let song = self
|
||||||
|
.search(query)
|
||||||
|
.ok_or_else(|| format!("song not found: {query}"))?;
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
let playlist_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM playlists WHERE name = ?1",
|
||||||
|
params![playlist],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|_| format!("playlist not found: {playlist}"))?;
|
||||||
|
Self::playlist_append_db(&conn, playlist_id, song.id).map_err(|e| e.to_string())?;
|
||||||
|
info!("Added {} to playlist {}", song.name, playlist);
|
||||||
|
let pl = Playlist {
|
||||||
|
id: playlist_id,
|
||||||
|
name: playlist.to_string(),
|
||||||
|
songs: Self::load_playlist_songs(&conn, playlist_id),
|
||||||
|
};
|
||||||
|
Ok((pl, song))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a playlist into the active queue (replacing the current queue).
|
||||||
|
pub fn play_playlist(&mut self, name: &str) -> Result<Playlist, String> {
|
||||||
|
let (playlist_id, songs) = {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
let playlist_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM playlists WHERE name = ?1",
|
||||||
|
params![name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|_| format!("playlist not found: {name}"))?;
|
||||||
|
let songs = Self::load_playlist_songs(&conn, playlist_id);
|
||||||
|
Self::queue_clear_db(&conn).map_err(|e| e.to_string())?;
|
||||||
|
for s in &songs {
|
||||||
|
Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
(playlist_id, songs)
|
||||||
|
};
|
||||||
|
// Guard is dropped; safe to mutate self.queue without borrow conflict.
|
||||||
|
self.queue = songs.clone();
|
||||||
|
info!("Playing playlist {name} ({} songs)", songs.len());
|
||||||
|
Ok(Playlist {
|
||||||
|
id: playlist_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
songs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_playlist(&self, name: &str) -> Result<Playlist, String> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
let playlist_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM playlists WHERE name = ?1",
|
||||||
|
params![name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|_| format!("playlist not found: {name}"))?;
|
||||||
|
Ok(Playlist {
|
||||||
|
id: playlist_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
songs: Self::load_playlist_songs(&conn, playlist_id),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user