diff --git a/client/sjnmusic b/client/sjnmusic index f63757b..df90e3f 100755 --- a/client/sjnmusic +++ b/client/sjnmusic @@ -1,36 +1,234 @@ #!/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 socket +import os 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: - print("usage: sjnmusic [value]") + +def build_parser() -> argparse.ArgumentParser: + 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) + if "queue" in obj and "current" in obj: + print("queue:") + for s in obj.get("queue", []): + print(f" - [{s['id']}] {s['name']}") + cur = obj.get("current") + if cur: + print(f"now playing: [{cur['id']}] {cur['name']}") + else: + print("now playing: (idle)") + 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)) - cmd = sys.argv[1] - value = " ".join(sys.argv[2:]) - if cmd == "play": - payload = {"Play": value} - elif cmd == "add": - payload = {"Add": value} - elif cmd == "del": - payload = {"Del": value} - elif cmd == "init": - payload = {"Init": value} +def main(argv=None): + 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: - print(f"unknown command: {cmd}") - sys.exit(1) + status, payload = call(host, port, "GET", "/help") - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(SOCKET_PATH) + print_response(args, status, payload) - sock.sendall(json.dumps(payload).encode()) - sock.close() if __name__ == "__main__": main() diff --git a/daemon/Cargo.lock b/daemon/Cargo.lock index 1544f0a..951b6f1 100644 --- a/daemon/Cargo.lock +++ b/daemon/Cargo.lock @@ -45,12 +45,68 @@ dependencies = [ "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]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "autocfg" version = "1.5.1" @@ -144,6 +200,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clang-sys" version = "1.8.1" @@ -161,6 +223,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -226,6 +294,38 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "dirs" version = "5.0.1" @@ -262,6 +362,29 @@ dependencies = [ "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]] name = "equivalent" version = "1.0.2" @@ -369,6 +492,12 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "indexmap" version = "2.14.0" @@ -379,6 +508,12 @@ dependencies = [ "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]] name = "itertools" version = "0.13.0" @@ -394,6 +529,31 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "jni" version = "0.21.1" @@ -405,7 +565,7 @@ dependencies = [ "combine", "jni-sys 0.3.1", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -550,7 +710,7 @@ dependencies = [ "log", "ndk-sys", "num_enum", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -658,12 +818,24 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -676,13 +848,50 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" 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]] @@ -717,7 +926,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -760,7 +969,7 @@ dependencies = [ "hound", "lewton", "symphonia", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -841,6 +1050,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -858,11 +1076,16 @@ name = "sjnmusicd" version = "0.1.0" dependencies = [ "dirs", + "env_logger", + "log", + "percent-encoding", "rodio", "rusqlite", "serde", "serde_json", "strsim", + "tiny_http", + "toml", ] [[package]] @@ -949,7 +1172,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 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]] @@ -963,6 +1195,29 @@ dependencies = [ "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]] name = "tinyvec" version = "1.11.0" @@ -978,6 +1233,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -987,6 +1263,20 @@ dependencies = [ "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]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -994,9 +1284,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -1005,15 +1295,27 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" 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]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1365,6 +1667,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index bcba8f2..686e49f 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -10,3 +10,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "5" strsim = "0.11" +tiny_http = "0.12" +log = "0.4" +env_logger = "0.11" +toml = "0.8" +percent-encoding = "2" diff --git a/daemon/src/audio.rs b/daemon/src/audio.rs index 9a63d64..3046a37 100644 --- a/daemon/src/audio.rs +++ b/daemon/src/audio.rs @@ -1,3 +1,4 @@ +use log::debug; use rodio::{Decoder, OutputStream, Sink}; use std::fs::File; use std::io::BufReader; @@ -15,17 +16,21 @@ impl AudioEngine { } } - pub fn play(&mut self, path: &str) { - let (_stream, handle) = OutputStream::try_default().unwrap(); - let sink = Sink::try_new(&handle).unwrap(); + pub fn play(&mut self, path: &str) -> Result<(), String> { + let (_stream, handle) = OutputStream::try_default() + .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 source = Decoder::new(BufReader::new(file)).unwrap(); + let file = File::open(path).map_err(|e| format!("could not open file: {e}"))?; + let source = + Decoder::new(BufReader::new(file)).map_err(|e| format!("decode error: {e}"))?; sink.append(source); + debug!("rodio sink appended for {path}"); self.sink = Some(sink); self._stream = Some(_stream); + Ok(()) } pub fn is_playing(&self) -> bool { diff --git a/daemon/src/config.rs b/daemon/src/config.rs new file mode 100644 index 0000000..b4949cb --- /dev/null +++ b/daemon/src/config.rs @@ -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::(&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() + } + } + } +} diff --git a/daemon/src/daemon.rs b/daemon/src/daemon.rs index a1e3322..a964f20 100644 --- a/daemon/src/daemon.rs +++ b/daemon/src/daemon.rs @@ -1,36 +1,99 @@ use std::sync::{Arc, Mutex}; use std::thread; +use log::{debug, error, info, warn}; + use crate::{ - state::DaemonState, - socket::start_socket, - db::init_db, audio::AudioEngine, + config::Config, + db::open_db, + server::start_server, + state::DaemonState, }; -pub fn run() { - let state = Arc::new(Mutex::new(DaemonState::new())); +pub fn run(cfg: Config) { + info!("Starting sjnmusicd"); - init_db(); + let conn = open_db(); + let state = Arc::new(Mutex::new(DaemonState::new(conn))); - let socket_state = state.clone(); - thread::spawn(move || { - start_socket(socket_state); - }); + // HTTP server thread. + let server_state = state.clone(); + 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(); + info!("Playback loop ready"); loop { { let mut state = state.lock().unwrap(); + // Clear "now playing" once the audio engine reports the song has + // ended so /now-playing and /queue reflect reality while the + // next iteration decides what's up next. + if !audio.is_playing() { + if let Some(prev) = state.current.take() { + debug!("Playback finished for {}", prev.name); + } + } + if !state.queue.is_empty() && !audio.is_playing() { - let song = state.queue.remove(0); - audio.play(&song.path); - state.current = Some(song); + // 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 + ); + } + } } } - std::thread::sleep(std::time::Duration::from_millis(100)); + thread::sleep(std::time::Duration::from_millis(200)); } } diff --git a/daemon/src/db.rs b/daemon/src/db.rs index 809adeb..a10d097 100644 --- a/daemon/src/db.rs +++ b/daemon/src/db.rs @@ -1,16 +1,54 @@ +use log::{debug, info}; use rusqlite::Connection; -use crate::paths::db_path; +use crate::paths::{db_path, ensure_dirs}; -pub fn init_db() { - let conn = Connection::open(db_path()).unwrap(); +/// Open (and configure) the SQLite database, creating tables on first run. +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 ( - id INTEGER PRIMARY KEY, - name TEXT UNIQUE, - path TEXT - )", - [], - ).unwrap(); + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + path TEXT NOT NULL + ); + + 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 } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 4ce52e0..fd10c19 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -1,11 +1,33 @@ -mod daemon; -mod socket; -mod protocol; -mod state; -mod db; mod audio; +mod config; +mod daemon; +mod db; mod paths; +mod server; +mod state; 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, + } } diff --git a/daemon/src/paths.rs b/daemon/src/paths.rs index 1dc3d0c..29ad075 100644 --- a/daemon/src/paths.rs +++ b/daemon/src/paths.rs @@ -34,3 +34,14 @@ pub fn db_path() -> String { p.push("songs.db"); 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()); +} diff --git a/daemon/src/protocol.rs b/daemon/src/protocol.rs deleted file mode 100644 index a661f53..0000000 --- a/daemon/src/protocol.rs +++ /dev/null @@ -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), -} diff --git a/daemon/src/server.rs b/daemon/src/server.rs new file mode 100644 index 0000000..f955f66 --- /dev/null +++ b/daemon/src/server.rs @@ -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(status: u16, body: &T) -> Response>> { + 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) -> Response>> { + json_response(400, &ApiError { error: msg.into() }) +} + +fn parse_body(body: &str) -> serde_json::Result { + if body.trim().is_empty() { + Ok(serde_json::Value::Null) + } else { + serde_json::from_str(body) + } +} + +pub fn start_server(state: Arc>, 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>, + method: &Method, + url: &str, +) -> Result<(), Box> { + 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 { + 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, + body: &str, +) -> Result { + 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 { + 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()) +} diff --git a/daemon/src/socket.rs b/daemon/src/socket.rs deleted file mode 100644 index 4f85049..0000000 --- a/daemon/src/socket.rs +++ /dev/null @@ -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>) { - 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::(&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>) -> 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() - } - } - } -} diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 027be1e..88060ce 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -1,139 +1,493 @@ -use std::process::Command as SysCommand; -use strsim::jaro_winkler; -use serde::{Serialize, Deserialize}; +use std::sync::Mutex; -#[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 id: i64, pub name: String, pub path: String, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Playlist { + pub id: i64, + pub name: String, + pub songs: Vec, +} + +/// 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 conn: Mutex, + /// Songs pending play, in order. The first element is the next to play. pub queue: Vec, + /// Currently playing song, if any. pub current: Option, } +const FUZZY_THRESHOLD: f64 = 0.65; + 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 { - queue: Vec::new(), + conn: Mutex::new(conn), + queue, current: None, } } - pub fn play(&mut self, query: &str) { - if let Some(song) = self.search(query) { - self.queue.insert(0, song); - } else { - eprintln!("Song nicht gefunden: {}", query); + fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> { + match self.conn.lock() { + Ok(g) => g, + Err(p) => { + warn!("DB mutex was poisoned, recovering"); + p.into_inner() + } } } - pub fn add(&mut self, query: &str) { - if let Some(song) = self.search(query) { - self.queue.push(song); - } else { - eprintln!("Song nicht gefunden: {}", query); - } + // ------------------------------------------------------------------ + // DB -> memory helpers + // ------------------------------------------------------------------ + + fn load_queue_from_db(conn: &Connection) -> Vec { + 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) { - if let Some(song) = self.search(query) { - let _ = std::fs::remove_file(&song.path); - self.queue.retain(|s| s.name != song.name); - } else { - eprintln!("Song nicht gefunden: {}", query); - } + /// Public wrapper around `all_songs`: returns every song in the DB, sorted + /// by name. Used by `DaemonState::search` (in-memory) and the `/songs` + /// HTTP route. + pub fn all_songs(&self) -> Vec { + Self::list_songs(&self.lock_conn()) } - pub fn init(&mut self, name: String) { - let path = crate::paths::song_path(&name); + fn list_songs(conn: &Connection) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let path = song_path(&name); + debug!("yt-dlp target path: {path}"); + + let output = std::process::Command::new("yt-dlp") .args([ "-x", "--audio-format", "mp3", "-o", &path, - &format!("ytsearch1:{}", name), + &format!("ytsearch1:{name}"), ]) .output(); - if result.is_err() { - eprintln!("INIT failed: yt-dlp not available or download error"); - return; + match output { + Ok(out) => { + 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); + + if !std::path::Path::new(&path).exists() { + return Err(format!("download finished but file missing: {path}")); + } + + let conn = self.lock_conn(); + 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, + }) } - pub fn search(&self, query: &str) -> Option { - use std::fs; - - let songs_dir = crate::paths::songs_dir(); - let query_lower = query.to_lowercase(); - - // Alle .mp3 Dateien aus songs_dir lesen - let entries = match fs::read_dir(&songs_dir) { - Ok(entries) => entries, - Err(_) => return None, + /// Clear the queue (memory + DB). + pub fn clear_queue(&mut self) -> Result<(), String> { + self.queue.clear(); + 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, 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, 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 { + 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; - const MIN_SCORE: f64 = 0.65; // Threshold - nicht zu aggressiv + let ids: Vec<(i64, String)> = stmt + .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 { - if let Ok(entry) = entry { - if let Ok(metadata) = entry.metadata() { - if metadata.is_file() { - if let Some(filename) = entry.file_name().to_str() { - // .mp3 entfernen - 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 - let score = jaro_winkler(&song_name_lower, &query_lower); - - if score > MIN_SCORE { - if best_match.is_none() || score > best_match.as_ref().unwrap().1 { - best_match = Some(( - entry.path().to_string_lossy().to_string(), - score - )); - } - } - } - } - } - } + ids.into_iter() + .map(|(id, name)| { + let songs = Self::load_playlist_songs(&conn, id); + Playlist { id, name, songs } + }) + .collect() + } + + fn load_playlist_songs(conn: &Connection, playlist_id: i64) -> Vec { + let mut stmt = match conn.prepare( + "SELECT s.id, s.name, s.path + FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id + WHERE ps.playlist_id = ?1 + ORDER BY ps.position ASC", + ) { + 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() + } + + pub fn create_playlist(&self, name: &str) -> Result { + let conn = self.lock_conn(); + conn.execute( + "INSERT OR IGNORE INTO playlists (name) VALUES (?1)", + params![name], + ) + .map_err(|e| e.to_string())?; + let id: i64 = conn + .query_row( + "SELECT id FROM playlists WHERE name = ?1", + 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(), + }) + } + + 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(()) + } - // Wenn fuzzy match gefunden, returne es - if let Some((path, _)) = best_match { - if let Some(filename) = std::path::Path::new(&path) - .file_name() - .and_then(|f| f.to_str()) { - let song_name = filename.strip_suffix(".mp3") - .or_else(|| filename.strip_suffix(".webm")) - .unwrap_or(filename); - return Some(Song { - name: song_name.to_string(), - path, - }); + 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 { + 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, + }) + } - None + pub fn get_playlist(&self, name: &str) -> Result { + 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), + }) } } diff --git a/nohup.out b/nohup.out new file mode 100644 index 0000000..e69de29