commit 173ea72d580c5c3ddf834d0dae1b5e460e7aca83 from: mtmn date: Sat Aug 8 19:13:45 2026 UTC add new mpd utilities and tests commit - 31e05974545e3f509c95cb9df3729822f9d3e3d6 commit + 173ea72d580c5c3ddf834d0dae1b5e460e7aca83 blob - 581075845b1c4c214c56fed9f07a5d7fbb525804 blob + 0237e323f7eab7456591a8e0022e4f9c9e5f533b --- .gitignore +++ .gitignore @@ -4,6 +4,8 @@ bin/* # python .venv/ +__pycache__/ +*.py[cod] # zig .zig-cache zig-out blob - 288cf09f95427e4f4072efcc28759f99eee863f6 blob + 0896a45076225f2350f9b0f4c72ff37cc072dc77 --- README.md +++ README.md @@ -54,7 +54,7 @@ Builds and copies binaries to `$HOME/.local/bin`: just deploy ``` -`deploy mpd` installs all five `mpd_*` binaries. Deployed Python tools +`deploy mpd` installs the configured `mpd_*` binaries. Deployed Python tools (diggah, lazymaster, nts, shuffle) are thin shims that run out of this repository's `.venv`, and deployed Ruby tools (hue, reflink-snap) run straight from the checkout; both need the checkout to stay in place. @@ -62,7 +62,7 @@ straight from the checkout; both need the checkout to ## Development ```sh -just test # run the Rust, Zig, and Go test suites +just test # run the Rust, Zig, Go, and MPD test suites just clippy # run Clippy over the Cargo workspace just rustfmt # format the Cargo workspace with rustfmt ``` blob - c431e972f2bea551cee2dd3b6b9584e3a2dc1ce0 blob + 16155eeab5531a0310685212cc3a4cccdfe5b6b8 --- justfile +++ justfile @@ -3,8 +3,8 @@ cargo := "cargo" deploy_dir := home_directory() / ".local/bin" -# The five C binaries deployed under the umbrella name "mpd" -mpd_bins := "mpd_add_to_playlist mpd_add_to_queue mpd_edit_queue mpd_update_library mpd_update_queue" +# The MPD C binaries deployed under the umbrella name "mpd" +mpd_bins := "mpd_add_to_playlist mpd_add_to_queue mpd_edit_queue mpd_update_library mpd_update_queue mpd_insert_next mpd_edit_playlist mpd_now_playing mpd_trim_queue mpd_report mpd_report_monthly" # Show this help message default: @@ -39,6 +39,7 @@ test: {{ cargo }} test --workspace cd pracomer && zig build test cd wrd && go test ./... + python3 -m unittest discover -s mpd/tests -v # Run `cargo clippy` over the workspace clippy: blob - 470a9d2fbe8cb274400e2916aa7169d44a728d57 blob + 9e9f7ddb5489025ed5c1191a8f4d23d36fc7ed24 --- knitfile +++ knitfile @@ -2,7 +2,7 @@ knit = require("knit") root = knit.abs(".") -cflags := -Wall -Wextra -O2 -march=native -mtune=native -fanalyzer -Wshadow -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -Wformat -Werror=format-security -Wnull-dereference -Wdangling-pointer -Wtrampolines -Walloca -Wcast-align=strict -Wdate-time -Wfloat-equal -Wpointer-arith -Wredundant-decls -Wswitch-default -Wvla -Wwrite-strings +cflags := -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -Wconversion -Wstrict-prototypes -O2 -march=native -mtune=native -fanalyzer -Wshadow -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -Wformat -Werror=format-security -Wnull-dereference -Wdangling-pointer -Wtrampolines -Walloca -Wcast-align=strict -Wdate-time -Wfloat-equal -Wpointer-arith -Wredundant-decls -Wswitch-default -Wvla -Wwrite-strings ldflags := -lmpdclient -Wl,-z,relro -Wl,-z,now cc := /usr/bin/cc @@ -36,21 +36,40 @@ $ bin/speediness: speediness/build.zig speediness/buil $ bin/wrd: wrd/go.mod $(knit.glob("wrd/*.go")) cd wrd && go build -o $(root)/$output . -$ bin/mpd_add_to_playlist: mpd/mpd_add_to_playlist.c - $cc $cflags -o $output $input $ldflags +$ bin/mpd_add_to_playlist: mpd/mpd_add_to_playlist.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_add_to_playlist.c mpd/mpd_common.c $ldflags -$ bin/mpd_add_to_queue: mpd/mpd_add_to_queue.c - $cc $cflags -o $output $input $ldflags +$ bin/mpd_add_to_queue: mpd/mpd_add_to_queue.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_add_to_queue.c mpd/mpd_common.c $ldflags -$ bin/mpd_edit_queue: mpd/mpd_edit_queue.c - $cc $cflags -o $output $input $ldflags +$ bin/mpd_edit_queue: mpd/mpd_edit_queue.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_edit_queue.c mpd/mpd_common.c $ldflags -$ bin/mpd_update_library: mpd/mpd_update_library.c - $cc $cflags -o $output $input $ldflags +$ bin/mpd_update_library: mpd/mpd_update_library.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_update_library.c mpd/mpd_common.c $ldflags -$ bin/mpd_update_queue: mpd/mpd_update_queue.c - $cc $cflags -o $output $input $ldflags +$ bin/mpd_update_queue: mpd/mpd_update_queue.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_update_queue.c mpd/mpd_common.c $ldflags +$ bin/mpd_insert_next: mpd/mpd_insert_next.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_insert_next.c mpd/mpd_common.c $ldflags + +$ bin/mpd_edit_playlist: mpd/mpd_edit_playlist.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_edit_playlist.c mpd/mpd_common.c $ldflags + +$ bin/mpd_now_playing: mpd/mpd_now_playing.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_now_playing.c mpd/mpd_common.c $ldflags + +$ bin/mpd_trim_queue: mpd/mpd_trim_queue.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_trim_queue.c mpd/mpd_common.c $ldflags + +$ bin/mpd_report: mpd/mpd_report.c mpd/mpd_common.c mpd/mpd_common.h + $cc $cflags -o $output mpd/mpd_report.c mpd/mpd_common.c $ldflags + +$ bin/mpd_report_monthly: mpd/mpd_report_monthly + cp mpd/mpd_report_monthly $output + chmod +x $output + $ .venv-stamp: requirements.txt python3 -m venv .venv .venv/bin/pip install --quiet --require-hashes -r requirements.txt @@ -88,7 +107,9 @@ $ all:VB: bin/dam bin/lastfm_rs bin/plants \ bin/pmn bin/pracomer bin/speediness \ bin/wrd \ bin/mpd_add_to_playlist bin/mpd_add_to_queue bin/mpd_edit_queue \ - bin/mpd_update_library bin/mpd_update_queue \ + bin/mpd_update_library bin/mpd_update_queue bin/mpd_insert_next \ + bin/mpd_edit_playlist bin/mpd_now_playing bin/mpd_trim_queue \ + bin/mpd_report bin/mpd_report_monthly \ bin/diggah bin/lazymaster bin/nts bin/shuffle bin/kundali \ bin/hue bin/reflink-snap } blob - 808c0e012b2192340043829e1ab568ca23eee246 blob + 4bec2cccc2a20e7291cb0ec0409101f37c6d65c5 --- mpd/README.md +++ mpd/README.md @@ -1,54 +1,63 @@ # mpd -A collection of C utilities for controlling [MPD](https://www.musicpd.org/) via libmpdclient. +Small C utilities for controlling [MPD](https://www.musicpd.org/) through +libmpdclient. List inputs contain one MPD URI per line; empty lines and lines +starting with `#` are ignored. Use `-` for stdin. -## Tools +## Commands -### mpd_add_to_queue +| Command | Usage | Purpose | +|---------|-------|---------| +| `mpd_add_to_queue` | `[--verbose] [FILE\|-]` | Append tracks to the queue. | +| `mpd_add_to_playlist` | `[--verbose] PLAYLIST [FILE\|-]` | Add tracks to a stored playlist; with terminal stdin, add the current song. | +| `mpd_edit_queue` | `[--force]` | Edit the queue with `$VISUAL`, `$EDITOR`, or `vi`. | +| `mpd_edit_playlist` | `[--force] PLAYLIST` | Edit a stored playlist. | +| `mpd_insert_next` | `[--verbose] [FILE\|-]` | Insert tracks after the current song, or at the front. | +| `mpd_now_playing` | `[--watch] [--json]` | Print the current song; watch mode uses MPD idle events. | +| `mpd_trim_queue` | `[--keep N]` | Remove played entries, optionally retaining the previous `N`. | +| `mpd_update_queue` | `[--force] [FILE\|-]` | Replace the queue while preserving playback where possible. | +| `mpd_update_library` | `[--no-wait] [--rescan] [FILE\|-]` | Update unique top-level directories, or the whole database with terminal stdin. | +| `mpd_report` | `[OPTIONS]` | Find database entries by `Last-Modified` time window. | +| `mpd_report_monthly` | `MONTH [YEAR] [MPD_REPORT_OPTION ...]` | Write the first four weekly reports for a month. | -Adds tracks to the MPD play queue. Reads a list of paths from a file or stdin. +Queue editing detects concurrent changes and attempts to restore the previous +queue after a replacement failure. Playlist edits stage non-empty replacements +before moving the old playlist aside. `--force` bypasses concurrency checks. -```sh -mpd_add_to_queue [file] -``` +`mpd_report` accepts repeatable `--window START,END` epoch-second ranges; `-` +means unbounded. Directories are emitted by default; `--files` includes songs, +`--path URI` limits the search, and `--relative` strips that prefix. Multiple +windows produce `INDEXURI`, including every matching overlapping window. +`--tracks-output FILE` atomically writes all song URIs during the same scan. -### mpd_add_to_playlist +`mpd_report_monthly` is a Ruby helper for the four weekly reports created by +`diggah -m MM -w`. Run `mpd_report_monthly 07 2026` to write +`1_07_2026.txt` through `4_07_2026.txt`; append `--files`, `--path URI`, or +other `mpd_report` options after `--`. It uses the current year when omitted, +accepts `-o DIRECTORY`, and replaces reports atomically after a successful MPD +query. -Adds tracks to a named MPD playlist. Reads a list of paths from a file or stdin. - -```sh -mpd_add_to_playlist [file] -``` - -### mpd_edit_queue - -Opens the current MPD queue in `$EDITOR` as a list of file paths. Saves and replaces the queue with the edited result, preserving the currently playing track's position where possible. - -```sh -mpd_edit_queue -``` - -### mpd_update_library - -Triggers an MPD library update and waits for it to complete, retrying on connection errors with exponential backoff. - -```sh -mpd_update_library -``` - -### mpd_update_queue - -Replaces the current MPD queue with a new list of tracks read from a file or stdin. - -```sh -mpd_update_queue [file] -``` - ## Configuration -All tools read connection settings from environment variables. +| Variable | Default | Purpose | +|----------|---------|---------| +| `MPD_HOST` | `localhost` | Hostname, Unix socket, or `password@host`. | +| `MPD_PORT` | `6600` | Server port. | +| `MPD_TIMEOUT` | libmpdclient default | Connection timeout in seconds. | +| `MPD_CONFIG_HELPER` | unset | Helper program to run. Set to `off` to skip `$HOME/bin/amen`. | +| `MPD_CONFIG_HELPER_TIMEOUT` | `10` | Helper timeout in seconds (1–3600). | -| Variable | Default | Description | -|----------|---------|-------------| -| `MPD_HOST` | `localhost` | MPD server hostname | -| `MPD_PORT` | `6600` | MPD server port | +If `MPD_CONFIG_HELPER` is unset, the tools try `$HOME/bin/amen`. If it is not +executable, they connect using `MPD_HOST` and `MPD_PORT` as usual. The helper +must print one `MPD_HOST=value` line and one `MPD_PORT=value` line. It is run +without a shell and only once per process. + +Exit status is `0` for success, `1` for runtime errors, and `2` for invalid +arguments. + +## Development + +```sh +just build mpd_report mpd_report_monthly +python3 -m unittest discover -s mpd/tests -v +``` blob - cb46f773814fde2901c943921d1cc3e2e245306d blob + 3e4bcfbcf7aa58375f53089ef30b23832d521174 --- mpd/mpd_add_to_playlist.c +++ mpd/mpd_add_to_playlist.c @@ -1,96 +1,96 @@ -#include -#include -#include +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + #include -// #define DEBUG -#ifdef DEBUG -#define D(x) \ - do { \ - x; \ - } while (0) -#else -#define D(x) \ - do { \ - } while (0) -#endif -#define DEFAULT_HOST "localhost" -#define DEFAULT_PORT 6600 -struct mpd_connection *conn() { - // Read host from environment variable, use default if not set - const char *host = getenv("MPD_HOST"); - if (host == NULL || strlen(host) == 0) { - host = DEFAULT_HOST; - } - D(printf("Using host: %s\n", host)); +#include - // Read port from environment variable, use default if not set - unsigned port = DEFAULT_PORT; - const char *port_str = getenv("MPD_PORT"); - if (port_str != NULL && strlen(port_str) > 0) { - port = (unsigned)atoi(port_str); - } - D(printf("Using port: %u\n", port)); - - D(printf("%s %s:%u\n", "Connecting to", host, port)); - - struct mpd_connection *c = mpd_connection_new(host, port, 0); - if (c == NULL) { - fprintf(stderr, "Failed to create MPD connection object\n"); - return NULL; - } - - if (mpd_connection_get_error(c) != MPD_ERROR_SUCCESS) { - fprintf(stderr, "Error connecting to MPD (%s:%u): %s\n", host, port, - mpd_connection_get_error_message(c)); - mpd_connection_free(c); - return NULL; - } -#ifdef PASS - const char *pass = PASS; - if (mpd_run_password(c, pass) == false) { - fprintf(stderr, "Bad password\n"); - mpd_connection_free(c); - return NULL; - } -#endif - D(printf("%s %s:%u\n", "Connected to", host, port)); - return c; +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--verbose] PLAYLIST [FILE|-]\n", program); } -int main(int argc, char *argv[]) { - // Check for playlist name argument - if (argc < 2) { - printf("Usage: %s PLAYLIST_NAME\n", argv[0]); - return 1; - } - const char *playlist = argv[1]; - D(printf("Using playlist: %s\n", playlist)); - - struct mpd_connection *c = conn(); - if (c == NULL) - return -1; - - struct mpd_song *curr = mpd_run_current_song(c); - if (curr == NULL) { - printf("No song is currently playing\n"); - mpd_connection_free(c); - return -1; +int main(int argc, char **argv) { + bool verbose = false; + const char *playlist = NULL; + const char *path = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--verbose") == 0) { + verbose = true; + } else if (argv[i][0] == '-' && strcmp(argv[i], "-") != 0) { + usage(stderr, argv[0]); + return 2; + } else if (playlist == NULL) { + playlist = argv[i]; + } else if (path == NULL) { + path = argv[i]; + } else { + usage(stderr, argv[0]); + return 2; + } } + if (playlist == NULL) { + usage(stderr, argv[0]); + return 2; + } - const char *curr_uri = mpd_song_get_uri(curr); - D(printf("Currently playing: %s\n", curr_uri)); + struct mt_strvec tracks; + mt_strvec_init(&tracks); + struct mpd_connection *connection = NULL; + bool ok = true; + size_t count = 0U; - if (mpd_run_playlist_add(c, playlist, curr_uri)) { - printf("%s %s %s %s\n", "Added", curr_uri, "to playlist", playlist); + if (path == NULL && isatty(STDIN_FILENO)) { + connection = mt_connect(); + if (connection == NULL) + return 1; + struct mpd_song *song = mpd_run_current_song(connection); + if (song == NULL) { + if (mpd_connection_get_error(connection) == MPD_ERROR_SUCCESS) + fprintf(stderr, "mpd: no current song\n"); + else + mt_report_error(connection, "get current song"); + ok = false; + } else { + const char *uri = mpd_song_get_uri(song); + ok = uri != NULL && mt_strvec_push(&tracks, uri); + if (!ok) + perror("mpd: store current song"); + mpd_song_free(song); + if (ok) + count = 1U; + } } else { - printf("%s\n", "Some error"); - mpd_song_free(curr); - mpd_connection_free(c); - return -1; + bool close_input; + FILE *input = mt_open_input(path, &close_input); + if (input == NULL) + return 1; + connection = mt_connect(); + if (connection == NULL) + ok = false; + if (ok) + ok = mt_add_playlist_stream(connection, playlist, input, verbose, &count); + if (close_input && fclose(input) != 0) { + perror("mpd: close input"); + ok = false; + } } - // Free resources - mpd_song_free(curr); - mpd_connection_free(c); - return 0; + if (ok && connection == NULL) + connection = mt_connect(); + if (ok && connection == NULL) + ok = false; + if (ok && tracks.len > 0U) + ok = mt_add_playlist(connection, playlist, &tracks, verbose); + if (ok) + printf("Added %zu track%s to %s.\n", count, count == 1U ? "" : "s", + playlist); + + if (connection != NULL) + mpd_connection_free(connection); + mt_strvec_clear(&tracks); + return ok ? 0 : 1; } blob - dd788e7b53f502ff8375fe03b9db76b5ff75cf74 blob + cdd91a1c0402b854f74df10df2a334c1ac5359a3 --- mpd/mpd_add_to_queue.c +++ mpd/mpd_add_to_queue.c @@ -1,123 +1,56 @@ -#include -#include -#include -#include -#include -#include - -// #define DEBUG -#ifdef DEBUG -#define D(x) \ - do { \ - x; \ - } while (0) -#else -#define D(x) \ - do { \ - } while (0) -#endif - -#define DEFAULT_HOST "localhost" -#define DEFAULT_PORT 6600 - -struct mpd_connection *conn() { - const char *host = getenv("MPD_HOST"); - if (host == NULL || strlen(host) == 0) { - host = DEFAULT_HOST; - } - - unsigned port = DEFAULT_PORT; - const char *port_str = getenv("MPD_PORT"); - if (port_str != NULL && strlen(port_str) > 0) { - port = (unsigned)atoi(port_str); - } - - struct mpd_connection *c = mpd_connection_new(host, port, 0); - if (c == NULL) { - fprintf(stderr, "Failed to create MPD connection object\n"); - return NULL; - } - - enum mpd_error err = mpd_connection_get_error(c); - if (err != 0) { - fprintf(stderr, "Error connecting to MPD (%s:%u): %s (code: %u)\n", host, - port, mpd_connection_get_error_message(c), err); - mpd_connection_free(c); - return NULL; - } -#ifdef PASS - const char *pass = PASS; - if (mpd_run_password(c, pass) == false) { - fprintf(stderr, "MPD authentication failed: %s\n", - mpd_connection_get_error_message(c)); - mpd_connection_free(c); - return NULL; - } -#endif - return c; -} - -int main(int argc, char *argv[]) { - FILE *input = stdin; - - if (argc > 1) { - input = fopen(argv[1], "r"); - if (input == NULL) { - perror("Error opening file"); - return 1; - } - } else if (isatty(STDIN_FILENO)) { - fprintf(stderr, "Reading from stdin... (Press Ctrl+D to finish, or provide " - "a filename as argument)\n"); - } - - struct mpd_connection *c = conn(); - if (c == NULL) { - if (input != stdin) - fclose(input); - return -1; - } - - char *line = NULL; - size_t len = 0; - ssize_t read_bytes; - int added_count = 0; - int failed_count = 0; - - while ((read_bytes = getline(&line, &len, input)) != -1) { - if (read_bytes > 0 && line[read_bytes - 1] == '\n') { - line[read_bytes - 1] = '\0'; - read_bytes--; - } - if (read_bytes > 0 && line[read_bytes - 1] == '\r') { - line[read_bytes - 1] = '\0'; - read_bytes--; - } - - if (read_bytes == 0) - continue; - if (line[0] == '#') - continue; - - if (!mpd_run_add(c, line)) { - fprintf(stderr, "Failed to add track: %s (Error: %s)\n", line, - mpd_connection_get_error_message(c)); - failed_count++; - if (!mpd_connection_clear_error(c)) - break; - } else { - printf("Added: %s\n", line); - added_count++; - } - } - - printf("\nSummary: Added %d tracks, failed to add %d tracks.\n", added_count, - failed_count); - - free(line); - if (input != stdin) - fclose(input); - mpd_connection_free(c); - - return 0; -} +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include +#include + +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--verbose] [FILE|-]\n", program); +} + +int main(int argc, char **argv) { + bool verbose = false; + const char *path = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--verbose") == 0) { + verbose = true; + } else if (argv[i][0] == '-' && strcmp(argv[i], "-") != 0) { + usage(stderr, argv[0]); + return 2; + } else if (path == NULL) { + path = argv[i]; + } else { + usage(stderr, argv[0]); + return 2; + } + } + + bool close_input; + FILE *input = mt_open_input(path, &close_input); + if (input == NULL) + return 1; + if (path == NULL && isatty(STDIN_FILENO)) + fprintf(stderr, "Reading tracks from stdin; press Ctrl-D to finish.\n"); + + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) { + if (close_input) + fclose(input); + return 1; + } + size_t count = 0U; + bool ok = mt_add_queue_stream(connection, input, verbose, &count); + if (close_input && fclose(input) != 0) { + perror("mpd: close input"); + ok = false; + } + if (ok) + printf("Added %zu track%s.\n", count, count == 1U ? "" : "s"); + mpd_connection_free(connection); + return ok ? 0 : 1; +} blob - 26577918c531049c42e790c0120324fe2ea6ecb5 blob + 05b500433b6cda1d354555f5d36f88338dbf72f3 --- mpd/mpd_edit_queue.c +++ mpd/mpd_edit_queue.c @@ -1,220 +1,58 @@ -#include -#include -#include +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include #include -#include -#include -#include -#include -#include -#define DEFAULT_HOST "localhost" -#define DEFAULT_PORT 6600 - -struct mpd_connection *conn() { - const char *host = getenv("MPD_HOST"); - if (host == NULL || strlen(host) == 0) host = DEFAULT_HOST; - - unsigned port = DEFAULT_PORT; - const char *port_str = getenv("MPD_PORT"); - if (port_str != NULL && strlen(port_str) > 0) port = (unsigned)atoi(port_str); - - struct mpd_connection *c = mpd_connection_new(host, port, 0); - if (c == NULL) { - fprintf(stderr, "Failed to create MPD connection object\n"); - return NULL; - } - - if (mpd_connection_get_error(c) != MPD_ERROR_SUCCESS) { - fprintf(stderr, "Error connecting to MPD (%s:%u): %s\n", host, port, mpd_connection_get_error_message(c)); - mpd_connection_free(c); - return NULL; - } - return c; +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--force]\n", program); } -char* trim_whitespace(char* str) { - char *end; - while(isspace((unsigned char)*str)) str++; - if(*str == 0) return str; - end = str + strlen(str) - 1; - while(end > str && isspace((unsigned char)*end)) end--; - end[1] = '\0'; - return str; -} - -int main() { - struct mpd_connection *c = conn(); - if (c == NULL) return 1; - - char tmp_template[] = "/tmp/mpd_queue_XXXXXX"; - int fd = mkstemp(tmp_template); - if (fd == -1) { - perror("mkstemp"); - mpd_connection_free(c); - return 1; +int main(int argc, char **argv) { + bool force = false; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; } - - FILE *f = fdopen(fd, "w"); - if (f == NULL) { - perror("fdopen"); - close(fd); - unlink(tmp_template); - mpd_connection_free(c); - return 1; + if (strcmp(argv[i], "--force") == 0) { + force = true; + } else { + usage(stderr, argv[0]); + return 2; } + } - if (!mpd_send_list_queue_meta(c)) { - fprintf(stderr, "Error sending queue request\n"); - fclose(f); - unlink(tmp_template); - mpd_connection_free(c); - return 1; - } + struct mt_strvec tracks; + mt_strvec_init(&tracks); + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) + return 1; - struct mpd_entity *entity; - while ((entity = mpd_recv_entity(c)) != NULL) { - if (mpd_entity_get_type(entity) == MPD_ENTITY_TYPE_SONG) { - const struct mpd_song *song = mpd_entity_get_song(entity); - fprintf(f, "%s\n", mpd_song_get_uri(song)); - } - mpd_entity_free(entity); - } - fclose(f); + struct mt_player_state state; + bool ok = mt_get_player_state(connection, &state); + unsigned version = UINT_MAX; + if (ok) { + version = state.queue_version; + mt_player_state_clear(&state); + ok = mt_read_queue(connection, &tracks); + } + mpd_connection_free(connection); - if (mpd_connection_get_error(c) != MPD_ERROR_SUCCESS) { - fprintf(stderr, "Error reading queue: %s\n", mpd_connection_get_error_message(c)); - unlink(tmp_template); - mpd_connection_free(c); - return 1; + if (ok) + ok = mt_edit_lines(&tracks); + if (ok) { + connection = mt_connect(); + if (connection == NULL) + ok = false; + else { + ok = mt_replace_queue(connection, &tracks, version, force); + mpd_connection_free(connection); } - mpd_response_finish(c); - - const char *editor = getenv("EDITOR"); - if (editor == NULL || strlen(editor) == 0) editor = "vi"; - - pid_t pid = fork(); - if (pid == -1) { - perror("fork"); - unlink(tmp_template); - mpd_connection_free(c); - return 1; - } else if (pid == 0) { - execlp(editor, editor, tmp_template, NULL); - perror("execlp"); - exit(1); - } - - int wstatus; - waitpid(pid, &wstatus, 0); - - /* Re-open temp file with O_NOFOLLOW to prevent symlink attacks */ - int fd2 = open(tmp_template, O_RDONLY | O_NOFOLLOW); - if (fd2 == -1) { - perror("open"); - unlink(tmp_template); - mpd_connection_free(c); - return 1; - } - f = fdopen(fd2, "r"); - if (f == NULL) { - perror("fdopen"); - close(fd2); - unlink(tmp_template); - mpd_connection_free(c); - return 1; - } - - char **new_tracks = NULL; - int track_count = 0; - char *line = NULL; - size_t len = 0; - ssize_t read_bytes; - char *current_file = NULL; - enum mpd_state state; - int current_pos; - int total_queue_len; - int new_pos = -1; - - while ((read_bytes = getline(&line, &len, f)) != -1) { - char *trimmed = trim_whitespace(line); - if (strlen(trimmed) == 0 || trimmed[0] == '#') continue; - - char **temp_tracks = realloc(new_tracks, sizeof(char *) * (track_count + 1)); - if (temp_tracks == NULL) { - perror("realloc"); - goto cleanup; - } - new_tracks = temp_tracks; - - new_tracks[track_count] = strdup(trimmed); - if (new_tracks[track_count] == NULL) { - perror("strdup"); - goto cleanup; - } - track_count++; - } - - /* Re-query MPD state after editor to avoid stale data */ - struct mpd_status *status = mpd_run_status(c); - if (status == NULL) { - fprintf(stderr, "Error getting status: %s\n", mpd_connection_get_error_message(c)); - goto cleanup; - } - state = mpd_status_get_state(status); - current_pos = mpd_status_get_song_pos(status); - total_queue_len = mpd_status_get_queue_length(status); - mpd_status_free(status); - - if (state == MPD_STATE_PLAY || state == MPD_STATE_PAUSE) { - struct mpd_song *song = mpd_run_current_song(c); - if (song != NULL) { - current_file = strdup(mpd_song_get_uri(song)); - mpd_song_free(song); - } - } - - /* Compute new_pos from fresh current_file */ - for (int i = 0; i < track_count; i++) { - if (current_file && strcmp(new_tracks[i], current_file) == 0 && new_pos == -1) { - new_pos = i; - } - } - - if (!mpd_command_list_begin(c, false)) { - fprintf(stderr, "Failed to start command list: %s\n", mpd_connection_get_error_message(c)); - goto cleanup; - } - - if (state == MPD_STATE_STOP || current_file == NULL || new_pos == -1) { - mpd_send_clear(c); - for (int i = 0; i < track_count; i++) { - mpd_send_add(c, new_tracks[i]); - } - if (state != MPD_STATE_STOP) mpd_send_play(c); - } else { - for (int i = total_queue_len - 1; i >= 0; i--) { - if (i != current_pos) mpd_send_delete(c, i); - } - for (int i = 0; i < track_count; i++) { - if (i != new_pos) mpd_send_add(c, new_tracks[i]); - } - if (new_pos > 0) mpd_send_move(c, 0, new_pos); - } - - if (!mpd_command_list_end(c) || !mpd_response_finish(c)) { - fprintf(stderr, "Failed to execute command list: %s\n", mpd_connection_get_error_message(c)); - } else { - printf("Queue updated successfully.\n"); - } - -cleanup: - free(line); - if (f) fclose(f); - unlink(tmp_template); - if (current_file) free(current_file); - for (int i = 0; i < track_count; i++) free(new_tracks[i]); - free(new_tracks); - mpd_connection_free(c); - return 0; + } + if (ok) + printf("Queue updated successfully.\n"); + mt_strvec_clear(&tracks); + return ok ? 0 : 1; } blob - /dev/null blob + 9f3eefa2ad358537cd4a2e021c57b69acde83ed7 (mode 644) --- /dev/null +++ mpd/mpd_common.c @@ -0,0 +1,1233 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +enum helper_state { + HELPER_UNRESOLVED, + HELPER_READY, + HELPER_FAILED, +}; + +static enum helper_state profile_helper_state; + +#define HELPER_DEFAULT_TIMEOUT_SECONDS 10U +#define HELPER_MAX_TIMEOUT_SECONDS 3600U +#define PLAYER_SNAPSHOT_RETRIES 3U + +static bool safe_helper_value(const char *value) { + if (*value == '\0') + return false; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor != '\0'; cursor++) { + if (*cursor < 0x20U || *cursor == 0x7fU) + return false; + } + return true; +} + +static bool parse_helper_port(const char *value) { + if (*value == '\0' || *value == '-') + return false; + errno = 0; + char *end; + unsigned long port = strtoul(value, &end, 10); + return errno == 0 && *end == '\0' && port > 0UL && port <= 65535UL; +} + +static unsigned helper_timeout_seconds(void) { + const char *configured = getenv("MPD_CONFIG_HELPER_TIMEOUT"); + if (configured == NULL || *configured == '\0') + return HELPER_DEFAULT_TIMEOUT_SECONDS; + + size_t timeout; + if (!mt_parse_size(configured, &timeout) || timeout == 0U || + timeout > HELPER_MAX_TIMEOUT_SECONDS) { + fprintf(stderr, + "mpd: MPD_CONFIG_HELPER_TIMEOUT must be between 1 and %u " + "seconds\n", + HELPER_MAX_TIMEOUT_SECONDS); + return 0U; + } + return (unsigned)timeout; +} + +static bool monotonic_now(struct timespec *value) { + if (clock_gettime(CLOCK_MONOTONIC, value) == 0) + return true; + perror("mpd: read monotonic clock"); + return false; +} + +static int milliseconds_until(const struct timespec *deadline) { + struct timespec now; + if (!monotonic_now(&now)) + return -1; + time_t seconds = deadline->tv_sec - now.tv_sec; + long nanoseconds = deadline->tv_nsec - now.tv_nsec; + if (nanoseconds < 0) { + seconds--; + nanoseconds += 1000000000L; + } + if (seconds < 0 || (seconds == 0 && nanoseconds == 0)) + return 0; + if (seconds > (time_t)(INT_MAX / 1000)) + return INT_MAX; + long milliseconds = nanoseconds / 1000000L; + if (nanoseconds % 1000000L != 0) + milliseconds++; + return (int)(seconds * 1000 + milliseconds); +} + +static void terminate_helper(pid_t pid) { + if (kill(-pid, SIGKILL) != 0 && errno != ESRCH) + perror("mpd: terminate config helper"); + int status; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } +} + +static bool wait_for_helper(pid_t pid, int *status, + const struct timespec *deadline) { + for (;;) { + pid_t result = waitpid(pid, status, WNOHANG); + if (result == pid) + return true; + if (result < 0) { + if (errno == EINTR) + continue; + perror("mpd: wait for config helper"); + return false; + } + int remaining = milliseconds_until(deadline); + if (remaining <= 0) + return false; + int pause = remaining < 20 ? remaining : 20; + struct timespec delay = {.tv_sec = 0, .tv_nsec = (long)pause * 1000000L}; + while (nanosleep(&delay, &delay) < 0 && errno == EINTR) { + } + } +} + +static bool parse_helper_output(char *output, size_t length, + const char *helper) { + char *host = NULL; + char *port = NULL; + bool valid = true; + + if (memchr(output, '\0', length) != NULL) + valid = false; + output[length] = '\0'; + char *cursor = output; + while (valid && *cursor != '\0') { + char *line = cursor; + char *newline = strchr(cursor, '\n'); + if (newline == NULL) { + cursor += strlen(cursor); + } else { + *newline = '\0'; + cursor = newline + 1; + } + size_t line_length = strlen(line); + while (line_length > 0U && line[line_length - 1U] == '\r') + line[--line_length] = '\0'; + if (line_length == 0U) + continue; + + char **destination = NULL; + const char *value = NULL; + if (strncmp(line, "MPD_HOST=", 9U) == 0) { + destination = &host; + value = line + 9; + } else if (strncmp(line, "MPD_PORT=", 9U) == 0) { + destination = &port; + value = line + 9; + } else { + valid = false; + continue; + } + if (*destination != NULL || !safe_helper_value(value) || + (destination == &port && !parse_helper_port(value))) { + valid = false; + continue; + } + *destination = strdup(value); + if (*destination == NULL) { + perror("mpd: store config helper output"); + valid = false; + } + } + + if (host == NULL || port == NULL) + valid = false; + if (!valid) { + fprintf(stderr, + "mpd: config helper '%s' must print exactly valid MPD_HOST and " + "MPD_PORT assignments\n", + helper); + } else if (setenv("MPD_HOST", host, 1) != 0 || + setenv("MPD_PORT", port, 1) != 0) { + perror("mpd: apply config helper output"); + valid = false; + } + + free(host); + free(port); + return valid; +} + +static bool collect_helper_output(int descriptor, pid_t pid, const char *helper, + unsigned timeout_seconds) { + int flags = fcntl(descriptor, F_GETFL); + if (flags < 0 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) != 0) { + perror("mpd: configure config helper output"); + close(descriptor); + terminate_helper(pid); + return false; + } + + struct timespec deadline; + if (!monotonic_now(&deadline)) { + close(descriptor); + terminate_helper(pid); + return false; + } + deadline.tv_sec += (time_t)timeout_seconds; + + char output[4098U]; + size_t length = 0U; + bool overflow = false; + bool complete = false; + while (!complete) { + char discard[1024U]; + void *destination = overflow ? (void *)discard : (void *)(output + length); + size_t available = overflow ? sizeof(discard) : 4097U - length; + ssize_t count = read(descriptor, destination, available); + if (count > 0) { + if (!overflow) { + length += (size_t)count; + if (length > 4096U) + overflow = true; + } + continue; + } + if (count == 0) { + complete = true; + break; + } + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + perror("mpd: read config helper output"); + close(descriptor); + terminate_helper(pid); + return false; + } + if (errno == EINTR) + continue; + + int remaining = milliseconds_until(&deadline); + if (remaining <= 0) + break; + struct pollfd poll_descriptor = {.fd = descriptor, .events = POLLIN}; + int result; + do { + result = poll(&poll_descriptor, 1U, remaining); + } while (result < 0 && errno == EINTR); + if (result < 0) { + perror("mpd: poll config helper output"); + close(descriptor); + terminate_helper(pid); + return false; + } + if (result == 0) + break; + } + if (close(descriptor) != 0) + perror("mpd: close config helper output"); + + int status = 0; + if (!complete || !wait_for_helper(pid, &status, &deadline)) { + fprintf(stderr, "mpd: config helper '%s' timed out after %u seconds\n", + helper, timeout_seconds); + terminate_helper(pid); + return false; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "mpd: config helper '%s' exited unsuccessfully\n", helper); + return false; + } + if (overflow) { + fprintf(stderr, "mpd: config helper '%s' output exceeds 4096 bytes\n", + helper); + return false; + } + return parse_helper_output(output, length, helper); +} + +static bool run_profile_helper(const char *helper) { + unsigned timeout_seconds = helper_timeout_seconds(); + if (timeout_seconds == 0U) + return false; + int descriptors[2]; + if (pipe(descriptors) != 0) { + perror("mpd: create config helper pipe"); + return false; + } + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + int error = posix_spawn_file_actions_init(&actions); + bool actions_initialized = error == 0; + bool attributes_initialized = false; + if (error == 0) + error = posix_spawn_file_actions_addclose(&actions, descriptors[0]); + if (error == 0) + error = posix_spawn_file_actions_adddup2(&actions, descriptors[1], + STDOUT_FILENO); + if (error == 0) + error = posix_spawn_file_actions_addclose(&actions, descriptors[1]); + if (error == 0) { + error = posix_spawnattr_init(&attributes); + attributes_initialized = error == 0; + } + if (error == 0) + error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP); + if (error == 0) + error = posix_spawnattr_setpgroup(&attributes, 0); + if (error != 0) { + fprintf(stderr, "mpd: prepare config helper: %s\n", strerror(error)); + if (actions_initialized) { + int destroy_error = posix_spawn_file_actions_destroy(&actions); + if (destroy_error != 0) + fprintf(stderr, "mpd: release config helper actions: %s\n", + strerror(destroy_error)); + } + if (attributes_initialized) { + int destroy_error = posix_spawnattr_destroy(&attributes); + if (destroy_error != 0) + fprintf(stderr, "mpd: release config helper attributes: %s\n", + strerror(destroy_error)); + } + close(descriptors[0]); + close(descriptors[1]); + return false; + } + + pid_t pid; + char *arguments[] = {(char *)helper, NULL}; + error = posix_spawn(&pid, helper, &actions, &attributes, arguments, environ); + int destroy_error = posix_spawn_file_actions_destroy(&actions); + if (destroy_error != 0) + fprintf(stderr, "mpd: release config helper actions: %s\n", + strerror(destroy_error)); + destroy_error = posix_spawnattr_destroy(&attributes); + if (destroy_error != 0) + fprintf(stderr, "mpd: release config helper attributes: %s\n", + strerror(destroy_error)); + if (error != 0) { + fprintf(stderr, "mpd: start config helper '%s': %s\n", helper, + strerror(error)); + close(descriptors[0]); + close(descriptors[1]); + return false; + } + + close(descriptors[1]); + return collect_helper_output(descriptors[0], pid, helper, timeout_seconds); +} + +static bool resolve_profile_helper(void) { + if (profile_helper_state != HELPER_UNRESOLVED) + return profile_helper_state == HELPER_READY; + + const char *configured = getenv("MPD_CONFIG_HELPER"); + if (configured != NULL && + (*configured == '\0' || strcmp(configured, "off") == 0)) { + profile_helper_state = HELPER_READY; + return true; + } + + char *default_helper = NULL; + const char *helper = configured; + if (helper == NULL) { + const char *home = getenv("HOME"); + if (home == NULL || *home == '\0') { + profile_helper_state = HELPER_READY; + return true; + } + size_t home_length = strlen(home); + if (home_length > SIZE_MAX - sizeof("/bin/amen")) { + fprintf(stderr, "mpd: HOME is too long\n"); + profile_helper_state = HELPER_FAILED; + return false; + } + size_t length = home_length + sizeof("/bin/amen"); + default_helper = malloc(length); + if (default_helper == NULL) { + perror("mpd: config helper path"); + profile_helper_state = HELPER_FAILED; + return false; + } + snprintf(default_helper, length, "%s/bin/amen", home); + if (access(default_helper, X_OK) != 0) { + free(default_helper); + profile_helper_state = HELPER_READY; + return true; + } + helper = default_helper; + } + + bool valid = run_profile_helper(helper); + free(default_helper); + profile_helper_state = valid ? HELPER_READY : HELPER_FAILED; + return valid; +} + +struct mpd_connection *mt_connect_to(const char *host, unsigned port) { + if (!resolve_profile_helper()) + return NULL; + struct mpd_connection *connection = mpd_connection_new(host, port, 0); + if (connection == NULL) { + fprintf(stderr, "mpd: out of memory while creating connection\n"); + return NULL; + } + + if (mpd_connection_get_error(connection) != MPD_ERROR_SUCCESS) { + mt_report_error(connection, "connect"); + mpd_connection_free(connection); + return NULL; + } + return connection; +} + +struct mpd_connection *mt_connect(void) { return mt_connect_to(NULL, 0U); } + +void mt_report_error(const struct mpd_connection *connection, + const char *context) { + const char *message = connection == NULL + ? "unknown MPD error" + : mpd_connection_get_error_message(connection); + fprintf(stderr, "mpd: %s: %s\n", context, message); +} + +void mt_strvec_init(struct mt_strvec *vec) { + vec->items = NULL; + vec->len = 0; + vec->cap = 0; +} + +void mt_strvec_clear(struct mt_strvec *vec) { + for (size_t i = 0; i < vec->len; i++) + free(vec->items[i]); + free(vec->items); + mt_strvec_init(vec); +} + +bool mt_strvec_push_n(struct mt_strvec *vec, const char *value, size_t length) { + if (vec->len == vec->cap) { + size_t cap = vec->cap == 0 ? 16U : vec->cap * 2U; + if (cap < vec->cap || cap > SIZE_MAX / sizeof(*vec->items)) { + errno = ENOMEM; + return false; + } + char **items = realloc(vec->items, cap * sizeof(*items)); + if (items == NULL) + return false; + vec->items = items; + vec->cap = cap; + } + + if (length == SIZE_MAX) { + errno = ENOMEM; + return false; + } + char *copy = malloc(length + 1U); + if (copy == NULL) + return false; + memcpy(copy, value, length); + copy[length] = '\0'; + vec->items[vec->len] = copy; + vec->len++; + return true; +} + +bool mt_strvec_push(struct mt_strvec *vec, const char *value) { + return mt_strvec_push_n(vec, value, strlen(value)); +} + +bool mt_read_lines(FILE *input, struct mt_strvec *lines) { + char *line = NULL; + size_t capacity = 0; + ssize_t length; + + while ((length = getline(&line, &capacity, input)) >= 0) { + while (length > 0 && (line[(size_t)length - 1U] == '\n' || + line[(size_t)length - 1U] == '\r')) + line[--length] = '\0'; + if (length == 0 || line[0] == '#') + continue; + if (!mt_strvec_push(lines, line)) { + perror("mpd: storing input line"); + free(line); + return false; + } + } + + free(line); + if (ferror(input)) { + perror("mpd: reading input"); + return false; + } + return true; +} + +FILE *mt_open_input(const char *path, bool *must_close) { + if (path == NULL || strcmp(path, "-") == 0) { + *must_close = false; + return stdin; + } + + FILE *input = fopen(path, "r"); + if (input == NULL) + perror(path); + *must_close = input != NULL; + return input; +} + +static bool finish_list(struct mpd_connection *connection, + const char *context) { + if (!mpd_command_list_end(connection) || !mpd_response_finish(connection)) { + mt_report_error(connection, context); + return false; + } + return true; +} + +bool mt_add_queue(struct mpd_connection *connection, + const struct mt_strvec *tracks, bool verbose) { + for (size_t start = 0; start < tracks->len; start += MPD_TOOLS_BATCH_SIZE) { + size_t end = start + MPD_TOOLS_BATCH_SIZE; + if (end > tracks->len) + end = tracks->len; + if (!mpd_command_list_begin(connection, false)) { + mt_report_error(connection, "begin add command list"); + return false; + } + for (size_t i = start; i < end; i++) { + if (!mpd_send_add(connection, tracks->items[i])) { + mt_report_error(connection, "queue add"); + return false; + } + } + if (!finish_list(connection, "add tracks")) { + fprintf(stderr, "mpd: failed input batch %zu..%zu\n", start + 1U, end); + return false; + } + if (verbose) { + for (size_t i = start; i < end; i++) + printf("Added: %s\n", tracks->items[i]); + } + } + return true; +} + +bool mt_insert_queue(struct mpd_connection *connection, + const struct mt_strvec *tracks, unsigned position, + bool verbose) { + size_t available = (size_t)UINT_MAX - (size_t)position + 1U; + if (tracks->len > available) { + fprintf(stderr, "mpd: too many tracks for the requested queue position\n"); + return false; + } + size_t inserted = 0; + while (inserted < tracks->len) { + size_t end = inserted + MPD_TOOLS_BATCH_SIZE; + if (end > tracks->len) + end = tracks->len; + if (!mpd_command_list_begin(connection, false)) { + mt_report_error(connection, "begin insert command list"); + return false; + } + for (size_t i = inserted; i < end; i++) { + if (i - inserted > UINT_MAX - position || + !mpd_send_add_id_to(connection, tracks->items[i], + position + (unsigned)(i - inserted))) { + mt_report_error(connection, "queue insert"); + return false; + } + } + if (!finish_list(connection, "insert tracks")) + return false; + if (end < tracks->len) + position += (unsigned)(end - inserted); + if (verbose) { + for (size_t i = inserted; i < end; i++) + printf("Inserted: %s\n", tracks->items[i]); + } + inserted = end; + } + return true; +} + +bool mt_add_playlist(struct mpd_connection *connection, const char *playlist, + const struct mt_strvec *tracks, bool verbose) { + for (size_t start = 0; start < tracks->len; start += MPD_TOOLS_BATCH_SIZE) { + size_t end = start + MPD_TOOLS_BATCH_SIZE; + if (end > tracks->len) + end = tracks->len; + if (!mpd_command_list_begin(connection, false)) { + mt_report_error(connection, "begin playlist command list"); + return false; + } + for (size_t i = start; i < end; i++) { + if (!mpd_send_playlist_add(connection, playlist, tracks->items[i])) { + mt_report_error(connection, "playlist add"); + return false; + } + } + if (!finish_list(connection, "add playlist tracks")) + return false; + if (verbose) { + for (size_t i = start; i < end; i++) + printf("Added to %s: %s\n", playlist, tracks->items[i]); + } + } + return true; +} + +static bool add_stream(struct mpd_connection *connection, const char *playlist, + FILE *input, bool verbose, size_t *count) { + struct mt_strvec batch; + mt_strvec_init(&batch); + char *line = NULL; + size_t capacity = 0U; + bool ok = true; + *count = 0U; + + while (ok) { + ssize_t length = getline(&line, &capacity, input); + if (length < 0) + break; + while (length > 0 && (line[(size_t)length - 1U] == '\n' || + line[(size_t)length - 1U] == '\r')) + line[--length] = '\0'; + if (length == 0 || line[0] == '#') + continue; + if (!mt_strvec_push(&batch, line)) { + perror("mpd: storing input line"); + ok = false; + } + if (ok && batch.len == MPD_TOOLS_BATCH_SIZE) { + ok = playlist == NULL + ? mt_add_queue(connection, &batch, verbose) + : mt_add_playlist(connection, playlist, &batch, verbose); + if (ok) + *count += batch.len; + mt_strvec_clear(&batch); + mt_strvec_init(&batch); + } + } + if (ok && ferror(input)) { + perror("mpd: reading input"); + ok = false; + } + if (ok && batch.len > 0U) { + ok = playlist == NULL + ? mt_add_queue(connection, &batch, verbose) + : mt_add_playlist(connection, playlist, &batch, verbose); + if (ok) + *count += batch.len; + } + free(line); + mt_strvec_clear(&batch); + return ok; +} + +bool mt_add_queue_stream(struct mpd_connection *connection, FILE *input, + bool verbose, size_t *count) { + return add_stream(connection, NULL, input, verbose, count); +} + +bool mt_add_playlist_stream(struct mpd_connection *connection, + const char *playlist, FILE *input, bool verbose, + size_t *count) { + return add_stream(connection, playlist, input, verbose, count); +} + +void mt_player_state_clear(struct mt_player_state *state) { + free(state->current_uri); + memset(state, 0, sizeof(*state)); + state->song_pos = UINT_MAX; + state->song_id = UINT_MAX; +} + +bool mt_get_player_state(struct mpd_connection *connection, + struct mt_player_state *state) { + for (unsigned attempt = 0U; attempt < PLAYER_SNAPSHOT_RETRIES; attempt++) { + memset(state, 0, sizeof(*state)); + state->song_pos = UINT_MAX; + state->song_id = UINT_MAX; + + struct mpd_status *status = mpd_run_status(connection); + if (status == NULL) { + mt_report_error(connection, "get status"); + return false; + } + state->state = mpd_status_get_state(status); + state->queue_version = mpd_status_get_queue_version(status); + state->queue_length = mpd_status_get_queue_length(status); + int song_pos = mpd_status_get_song_pos(status); + int song_id = mpd_status_get_song_id(status); + if (song_pos >= 0) + state->song_pos = (unsigned)song_pos; + if (song_id >= 0) + state->song_id = (unsigned)song_id; + mpd_status_free(status); + + if (state->song_id == UINT_MAX) + return true; + + struct mpd_song *song = mpd_run_current_song(connection); + if (song == NULL) { + if (mpd_connection_get_error(connection) != MPD_ERROR_SUCCESS) { + mt_report_error(connection, "get current song"); + return false; + } + continue; + } + bool consistent = mpd_song_get_id(song) == state->song_id && + mpd_song_get_pos(song) == state->song_pos; + const char *uri = mpd_song_get_uri(song); + bool has_uri = uri != NULL; + if (consistent && has_uri) + state->current_uri = strdup(uri); + mpd_song_free(song); + if (consistent && has_uri && state->current_uri == NULL) { + perror("mpd: current song"); + return false; + } + if (consistent) + return true; + } + fprintf(stderr, "mpd: player changed repeatedly while reading its state\n"); + return false; +} + +static bool receive_song_uris(struct mpd_connection *connection, + struct mt_strvec *tracks) { + struct mpd_entity *entity; + while ((entity = mpd_recv_entity(connection)) != NULL) { + if (mpd_entity_get_type(entity) == MPD_ENTITY_TYPE_SONG) { + const struct mpd_song *song = mpd_entity_get_song(entity); + const char *uri = mpd_song_get_uri(song); + if (uri != NULL && !mt_strvec_push(tracks, uri)) { + mpd_entity_free(entity); + perror("mpd: storing queue"); + return false; + } + } + mpd_entity_free(entity); + } + if (!mpd_response_finish(connection)) { + mt_report_error(connection, "receive song list"); + return false; + } + return true; +} + +bool mt_read_queue(struct mpd_connection *connection, + struct mt_strvec *tracks) { + if (!mpd_send_list_queue_meta(connection)) { + mt_report_error(connection, "list queue"); + return false; + } + return receive_song_uris(connection, tracks); +} + +bool mt_read_playlist(struct mpd_connection *connection, const char *playlist, + struct mt_strvec *tracks) { + if (!mpd_send_list_playlist(connection, playlist)) { + mt_report_error(connection, "list stored playlist"); + return false; + } + return receive_song_uris(connection, tracks); +} + +enum mt_state_match mt_player_state_is(struct mpd_connection *connection, + unsigned expected_version, + unsigned expected_song_id) { + struct mpd_status *status = mpd_run_status(connection); + if (status == NULL) { + mt_report_error(connection, "check player state"); + return MT_STATE_ERROR; + } + int song_id = mpd_status_get_song_id(status); + bool song_matches = + expected_song_id == UINT_MAX + ? song_id < 0 + : song_id >= 0 && (unsigned)song_id == expected_song_id; + bool matches = + mpd_status_get_queue_version(status) == expected_version && song_matches; + mpd_status_free(status); + return matches ? MT_STATE_MATCH : MT_STATE_CHANGED; +} + +static size_t first_uri(const struct mt_strvec *tracks, const char *uri) { + if (uri == NULL) + return SIZE_MAX; + for (size_t i = 0; i < tracks->len; i++) { + if (strcmp(tracks->items[i], uri) == 0) + return i; + } + return SIZE_MAX; +} + +static bool add_queue_skipping(struct mpd_connection *connection, + const struct mt_strvec *tracks, size_t skip) { + size_t index = 0U; + while (index < tracks->len) { + while (index < tracks->len && index == skip) + index++; + if (index == tracks->len) + break; + + if (!mpd_command_list_begin(connection, false)) { + mt_report_error(connection, "begin replacement add list"); + return false; + } + size_t sent = 0U; + while (index < tracks->len && sent < MPD_TOOLS_BATCH_SIZE) { + if (index != skip) { + if (!mpd_send_add(connection, tracks->items[index])) { + mt_report_error(connection, "add replacement track"); + return false; + } + sent++; + } + index++; + } + if (!finish_list(connection, "add replacement tracks")) + return false; + } + return true; +} + +static bool restore_queue_snapshot(const struct mt_strvec *tracks, + const struct mt_player_state *state) { + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) + return false; + bool ok = mpd_run_clear(connection); + if (!ok) + mt_report_error(connection, "rollback: clear partial queue"); + if (ok) + ok = mt_add_queue(connection, tracks, false); + if (ok && tracks->len > 0U && state->song_pos < tracks->len && + (state->state == MPD_STATE_PLAY || state->state == MPD_STATE_PAUSE)) { + ok = mpd_run_play_pos(connection, state->song_pos); + if (!ok) { + mt_report_error(connection, "rollback: restore playback position"); + } else if (state->state == MPD_STATE_PAUSE) { + ok = mpd_run_pause(connection, true); + if (!ok) + mt_report_error(connection, "rollback: restore paused state"); + } + } + mpd_connection_free(connection); + return ok; +} + +bool mt_replace_queue(struct mpd_connection *connection, + const struct mt_strvec *tracks, unsigned expected_version, + bool force) { + struct mt_player_state state; + struct mt_strvec original; + mt_strvec_init(&original); + bool snapshot_ready = false; + bool state_changed = false; + for (unsigned attempt = 0U; attempt < PLAYER_SNAPSHOT_RETRIES; attempt++) { + if (!mt_get_player_state(connection, &state)) + break; + if (!force && expected_version != UINT_MAX && + state.queue_version != expected_version) { + fprintf(stderr, "mpd: queue changed while it was being edited; use " + "--force to replace it anyway\n"); + mt_player_state_clear(&state); + break; + } + if (!mt_read_queue(connection, &original)) { + mt_player_state_clear(&state); + break; + } + enum mt_state_match match = + mt_player_state_is(connection, state.queue_version, state.song_id); + if (match == MT_STATE_MATCH) { + snapshot_ready = true; + break; + } + if (match == MT_STATE_CHANGED) + state_changed = true; + mt_player_state_clear(&state); + mt_strvec_clear(&original); + mt_strvec_init(&original); + if (match == MT_STATE_ERROR) + break; + } + if (!snapshot_ready) { + if (state_changed) + fprintf(stderr, "mpd: player changed repeatedly before replacement\n"); + mt_strvec_clear(&original); + return false; + } + + size_t keep = first_uri(tracks, state.current_uri); + bool can_keep = keep != SIZE_MAX && state.song_pos != UINT_MAX && + state.song_pos < state.queue_length; + bool ok = true; + bool mutation_started = false; + + if (can_keep) { + bool needs_trim = + state.song_pos > 0U || state.song_pos + 1U < state.queue_length; + if (needs_trim) { + mutation_started = true; + if (!mpd_command_list_begin(connection, false)) { + mt_report_error(connection, "begin queue trim"); + ok = false; + } + if (ok && state.song_pos + 1U < state.queue_length && + !mpd_send_delete_range(connection, state.song_pos + 1U, + state.queue_length)) { + mt_report_error(connection, "delete queue suffix"); + ok = false; + } + if (ok && state.song_pos > 0U && + !mpd_send_delete_range(connection, 0U, state.song_pos)) { + mt_report_error(connection, "delete queue prefix"); + ok = false; + } + if (ok) + ok = finish_list(connection, "trim queue around current song"); + } + if (ok) { + mutation_started = true; + ok = add_queue_skipping(connection, tracks, keep); + } + if (ok && keep > 0U && !mpd_run_move(connection, 0U, (unsigned)keep)) { + mt_report_error(connection, "restore current queue position"); + ok = false; + } + } else { + mutation_started = true; + if (!mpd_run_clear(connection)) { + mt_report_error(connection, "clear queue"); + ok = false; + } + if (ok) + ok = mt_add_queue(connection, tracks, false); + if (ok && tracks->len > 0U && + (state.state == MPD_STATE_PLAY || state.state == MPD_STATE_PAUSE)) { + if (!mpd_run_play_pos(connection, 0U)) { + mt_report_error(connection, "restart playback"); + ok = false; + } else if (state.state == MPD_STATE_PAUSE && + !mpd_run_pause(connection, true)) { + mt_report_error(connection, "restore paused state"); + ok = false; + } + } + } + + if (!ok && mutation_started) { + fprintf(stderr, "mpd: replacement failed; restoring the previous queue\n"); + if (!restore_queue_snapshot(&original, &state)) + fprintf(stderr, "mpd: rollback failed; the queue may be partial\n"); + } + + mt_player_state_clear(&state); + mt_strvec_clear(&original); + return ok; +} + +static bool same_tracks(const struct mt_strvec *left, + const struct mt_strvec *right) { + if (left->len != right->len) + return false; + for (size_t i = 0U; i < left->len; i++) { + if (strcmp(left->items[i], right->items[i]) != 0) + return false; + } + return true; +} + +static unsigned long playlist_name_sequence; + +static void temporary_playlist_name(char *buffer, size_t size, + const char *purpose) { + playlist_name_sequence++; + struct timespec now = {0}; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) + now.tv_nsec = (long)playlist_name_sequence; + snprintf(buffer, size, "mpd-tools-%ld-%lld-%ld-%lu-%s", (long)getpid(), + (long long)now.tv_sec, now.tv_nsec, playlist_name_sequence, purpose); +} + +static void remove_temporary_playlist(const char *playlist) { + struct mpd_connection *cleanup = mt_connect(); + if (cleanup == NULL) + return; + if (!mpd_run_rm(cleanup, playlist) && + mpd_connection_get_error(cleanup) != MPD_ERROR_SERVER) + mt_report_error(cleanup, "remove temporary playlist"); + mpd_connection_free(cleanup); +} + +bool mt_replace_playlist(struct mpd_connection *connection, + const char *playlist, const struct mt_strvec *tracks, + const struct mt_strvec *expected, bool force) { + if (tracks->len == 0U) { + if (!force) { + struct mt_strvec current; + mt_strvec_init(¤t); + bool read_ok = mt_read_playlist(connection, playlist, ¤t); + bool unchanged = read_ok && same_tracks(expected, ¤t); + mt_strvec_clear(¤t); + if (!read_ok) + return false; + if (!unchanged) { + fprintf(stderr, "mpd: stored playlist changed while it was being " + "edited; use --force to replace it anyway\n"); + return false; + } + } + if (!mpd_run_playlist_clear(connection, playlist)) { + mt_report_error(connection, "clear stored playlist"); + return false; + } + return true; + } + + char staged[128U]; + char backup[128U]; + temporary_playlist_name(staged, sizeof(staged), "new"); + temporary_playlist_name(backup, sizeof(backup), "old"); + if (!mt_add_playlist(connection, staged, tracks, false)) { + remove_temporary_playlist(staged); + return false; + } + + if (!force) { + struct mt_strvec current; + mt_strvec_init(¤t); + bool read_ok = mt_read_playlist(connection, playlist, ¤t); + bool unchanged = read_ok && same_tracks(expected, ¤t); + mt_strvec_clear(¤t); + if (!read_ok) { + remove_temporary_playlist(staged); + return false; + } + if (!unchanged) { + fprintf(stderr, "mpd: stored playlist changed while it was being " + "edited; use --force to replace it anyway\n"); + remove_temporary_playlist(staged); + return false; + } + } + + if (!mpd_run_rename(connection, playlist, backup)) { + mt_report_error(connection, "move old stored playlist aside"); + remove_temporary_playlist(staged); + return false; + } + if (!mpd_run_rename(connection, staged, playlist)) { + mt_report_error(connection, "install staged stored playlist"); + struct mpd_connection *rollback = mt_connect(); + if (rollback == NULL || !mpd_run_rename(rollback, backup, playlist)) + fprintf(stderr, + "mpd: playlist rollback failed; '%s' contains the " + "backup\n", + backup); + if (rollback != NULL) + mpd_connection_free(rollback); + remove_temporary_playlist(staged); + return false; + } + if (!mpd_run_rm(connection, backup)) { + mt_report_error(connection, "remove stored playlist backup"); + fprintf(stderr, "mpd: replacement succeeded, but backup '%s' remains\n", + backup); + return false; + } + return true; +} + +static bool run_editor(const char *path) { + const char *editor = getenv("VISUAL"); + if (editor == NULL || *editor == '\0') + editor = getenv("EDITOR"); + if (editor == NULL || *editor == '\0') + editor = "vi"; + + wordexp_t words; + int result = wordexp(editor, &words, WRDE_NOCMD); + if (result != 0 || words.we_wordc == 0U) { + fprintf(stderr, "mpd: invalid editor command\n"); + if (result == 0) + wordfree(&words); + return false; + } + + char **args = calloc(words.we_wordc + 2U, sizeof(*args)); + if (args == NULL) { + perror("mpd: editor arguments"); + wordfree(&words); + return false; + } + for (size_t i = 0; i < words.we_wordc; i++) + args[i] = words.we_wordv[i]; + args[words.we_wordc] = (char *)path; + + pid_t pid = fork(); + if (pid == 0) { + execvp(args[0], args); + perror(args[0]); + _exit(127); + } + if (pid < 0) { + perror("mpd: fork editor"); + free(args); + wordfree(&words); + return false; + } + + int status; + while (waitpid(pid, &status, 0) < 0) { + if (errno != EINTR) { + perror("mpd: wait for editor"); + free(args); + wordfree(&words); + return false; + } + } + free(args); + wordfree(&words); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "mpd: editor exited unsuccessfully\n"); + return false; + } + return true; +} + +static char *editor_file_template(void) { + const char *directory = getenv("TMPDIR"); + if (directory == NULL || *directory == '\0') + directory = "/tmp"; + size_t directory_length = strlen(directory); + bool needs_slash = directory[directory_length - 1U] != '/'; + const char suffix[] = "mpd_tools_XXXXXX"; + if (directory_length > SIZE_MAX - sizeof(suffix) - (size_t)needs_slash) { + errno = ENAMETOOLONG; + return NULL; + } + size_t length = directory_length + (size_t)needs_slash + sizeof(suffix); + char *path = malloc(length); + if (path == NULL) + return NULL; + snprintf(path, length, "%s%s%s", directory, needs_slash ? "/" : "", suffix); + return path; +} + +bool mt_edit_lines(struct mt_strvec *lines) { + char *path = editor_file_template(); + if (path == NULL) { + perror("mpd: create editor file path"); + return false; + } + int fd = mkstemp(path); + if (fd < 0) { + perror("mpd: create editor file"); + free(path); + return false; + } + FILE *file = fdopen(fd, "w"); + if (file == NULL) { + perror("mpd: open editor file"); + close(fd); + unlink(path); + free(path); + return false; + } + bool ok = true; + for (size_t i = 0; i < lines->len; i++) { + if (fprintf(file, "%s\n", lines->items[i]) < 0) { + perror("mpd: write editor file"); + ok = false; + break; + } + } + if (fclose(file) != 0) { + perror("mpd: close editor file"); + ok = false; + } + + if (ok) + ok = run_editor(path); + if (ok) { + int read_fd = open(path, O_RDONLY | O_NOFOLLOW); + if (read_fd < 0) { + perror("mpd: reopen editor file"); + ok = false; + } else { + file = fdopen(read_fd, "r"); + if (file == NULL) { + perror("mpd: read editor file"); + close(read_fd); + ok = false; + } else { + struct mt_strvec edited; + mt_strvec_init(&edited); + ok = mt_read_lines(file, &edited); + if (fclose(file) != 0) { + perror("mpd: close edited file"); + ok = false; + } + if (ok) { + mt_strvec_clear(lines); + *lines = edited; + } else { + mt_strvec_clear(&edited); + } + } + } + } + + if (unlink(path) != 0) + perror("mpd: remove editor file"); + free(path); + return ok; +} + +bool mt_parse_size(const char *text, size_t *value) { + if (text == NULL || *text == '\0' || *text == '-') + return false; + errno = 0; + char *end; + unsigned long long parsed = strtoull(text, &end, 10); + if (errno != 0 || *end != '\0' || parsed > SIZE_MAX) + return false; + *value = (size_t)parsed; + return true; +} blob - 9e8b94e562dee855eda37d4c848e2d1c428d70bd blob + 0e843e665b489bd463cf0bfcedb78382033d1f66 --- mpd/mpd_update_library.c +++ mpd/mpd_update_library.c @@ -1,169 +1,207 @@ -#include -#include -#include -#include +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include #include #include +#include #include -#define DEFAULT_HOST "localhost" -#define DEFAULT_PORT 6600 -#define DEFAULT_BACKOFF 5 +#define UPDATE_RETRIES 5U -struct mpd_connection *conn() { - const char *host = getenv("MPD_HOST"); - if (host == NULL || strlen(host) == 0) - host = DEFAULT_HOST; - - unsigned port = DEFAULT_PORT; - const char *port_str = getenv("MPD_PORT"); - if (port_str != NULL && strlen(port_str) > 0) - port = (unsigned)atoi(port_str); - - struct mpd_connection *c = mpd_connection_new(host, port, 0); - if (c == NULL) { - fprintf(stderr, "Failed to create MPD connection object\n"); - return NULL; - } - - if (mpd_connection_get_error(c) != MPD_ERROR_SUCCESS) { - fprintf(stderr, "Error connecting to MPD (%s:%u): %s\n", host, port, - mpd_connection_get_error_message(c)); - mpd_connection_free(c); - return NULL; - } - return c; +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--no-wait] [--rescan] [FILE|-]\n", program); } -char *trim_whitespace(char *str) { - char *end; - while (isspace((unsigned char)*str)) - str++; - if (*str == 0) - return str; - end = str + strlen(str) - 1; - while (end > str && isspace((unsigned char)*end)) - end--; - end[1] = '\0'; - return str; +static int compare_strings(const void *left, const void *right) { + const char *const *a = left; + const char *const *b = right; + return strcmp(*a, *b); } -int main(int argc, char *argv[]) { - if (argc > 1 && - (strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "--help") == 0)) { - printf("Usage: %s [input_file]\n", argv[0]); - return 0; - } - - FILE *input = stdin; - if (argc > 1) { - input = fopen(argv[1], "r"); - if (input == NULL) { - perror("Error opening file"); - return 1; +static bool top_directories(const struct mt_strvec *paths, + struct mt_strvec *directories) { + for (size_t i = 0; i < paths->len; i++) { + const char *slash = strchr(paths->items[i], '/'); + size_t length = slash == NULL ? strlen(paths->items[i]) + : (size_t)(slash - paths->items[i]); + if (!mt_strvec_push_n(directories, paths->items[i], length)) { + perror("mpd: store update directory"); + return false; } - } else if (isatty(STDIN_FILENO)) { - fprintf(stderr, "Reading from stdin... (Press Ctrl+D to finish)\n"); } - char **top_dirs = NULL; - int dir_count = 0; - char *line = NULL; - size_t len = 0; - ssize_t read_bytes; - - while ((read_bytes = getline(&line, &len, input)) != -1) { - char *trimmed = trim_whitespace(line); - if (strlen(trimmed) == 0 || trimmed[0] == '#') - continue; - - char *slash = strchr(trimmed, '/'); - if (slash != NULL) - *slash = '\0'; - - bool found = false; - for (int i = 0; i < dir_count; i++) { - if (strcmp(top_dirs[i], trimmed) == 0) { - found = true; - break; - } - } - - if (!found) { - char **temp_dirs = realloc(top_dirs, sizeof(char *) * (dir_count + 1)); - if (temp_dirs == NULL) { - perror("realloc"); - break; - } - top_dirs = temp_dirs; - top_dirs[dir_count] = strdup(trimmed); - if (top_dirs[dir_count] == NULL) { - perror("strdup"); - break; - } - dir_count++; - } - } - free(line); - if (input != stdin) - fclose(input); - - if (dir_count == 0) { - printf("No directories found in input.\n"); - for (int i = 0; i < dir_count; i++) - free(top_dirs[i]); - free(top_dirs); - return 0; - } - - printf("Found %d unique top-level directories to update.\n", dir_count); - - struct mpd_connection *c = conn(); - if (c == NULL) { - for (int i = 0; i < dir_count; i++) - free(top_dirs[i]); - free(top_dirs); - return 1; - } - - int backoff = DEFAULT_BACKOFF; - const char *backoff_str = getenv("MPD_UPDATE_BACKOFF"); - if (backoff_str != NULL && strlen(backoff_str) > 0) - backoff = atoi(backoff_str); - - int updated_count = 0; - int error_count = 0; - - for (int i = 0; i < dir_count; i++) { - printf("Updating: '%s'\n", top_dirs[i]); - unsigned job_id = mpd_run_update(c, top_dirs[i]); - if (job_id == 0) { - fprintf(stderr, " ✗ Failed: %s\n", mpd_connection_get_error_message(c)); - error_count++; - mpd_connection_clear_error(c); + qsort(directories->items, directories->len, sizeof(*directories->items), + compare_strings); + size_t output = 0; + for (size_t i = 0; i < directories->len; i++) { + if (output > 0U && + strcmp(directories->items[output - 1U], directories->items[i]) == 0) { + free(directories->items[i]); } else { - printf(" ✓ Success (Job ID: %u)\n", job_id); - updated_count++; + directories->items[output++] = directories->items[i]; } - if (i < dir_count - 1 && backoff > 0) - sleep(backoff); } + directories->len = output; + return true; +} - printf("Update completed!\n"); - printf("Total directories processed: %d\n", dir_count); - printf("Successfully updated: %d\n", updated_count); - printf("Errors: %d\n", error_count); +static void retry_delay(unsigned attempt) { + unsigned milliseconds = 250U << attempt; + if (milliseconds > 4000U) + milliseconds = 4000U; + struct timespec delay = {.tv_sec = (time_t)(milliseconds / 1000U), + .tv_nsec = (long)(milliseconds % 1000U) * 1000000L}; + while (nanosleep(&delay, &delay) < 0 && errno == EINTR) { + } +} - struct mpd_status *status = mpd_run_status(c); - if (status != NULL) { - if (mpd_status_get_update_id(status) > 0) - printf("MPD is currently updating...\n"); +static struct mpd_connection *reconnect(unsigned attempt) { + if (attempt > 0U) + retry_delay(attempt - 1U); + return mt_connect(); +} + +static bool submit_directory(struct mpd_connection **connection, + const char *directory, bool rescan) { + for (unsigned attempt = 0; attempt < UPDATE_RETRIES; attempt++) { + if (*connection == NULL) + *connection = reconnect(attempt); + if (*connection == NULL) + continue; + + unsigned id = rescan ? mpd_run_rescan(*connection, directory) + : mpd_run_update(*connection, directory); + if (id != 0U) { + printf("Submitted update for '%s' (job %u).\n", directory, id); + return true; + } + + enum mpd_error error = mpd_connection_get_error(*connection); + mt_report_error(*connection, rescan ? "submit rescan" : "submit update"); + if (error == MPD_ERROR_SERVER || error == MPD_ERROR_ARGUMENT) + return false; + mpd_connection_free(*connection); + *connection = NULL; + } + fprintf(stderr, "mpd: exhausted retries for '%s'\n", directory); + return false; +} + +static bool wait_for_updates(struct mpd_connection **connection) { + for (unsigned failures = 0; failures < UPDATE_RETRIES;) { + if (*connection == NULL) + *connection = reconnect(failures); + if (*connection == NULL) { + failures++; + continue; + } + + struct mpd_status *status = mpd_run_status(*connection); + if (status == NULL) { + mt_report_error(*connection, "check update status"); + mpd_connection_free(*connection); + *connection = NULL; + failures++; + continue; + } + unsigned update_id = mpd_status_get_update_id(status); mpd_status_free(status); + if (update_id == 0U) + return true; + + enum mpd_idle event = mpd_run_idle_mask(*connection, MPD_IDLE_UPDATE); + if ((event & MPD_IDLE_UPDATE) != 0) { + failures = 0; + continue; + } + mt_report_error(*connection, "wait for database update"); + mpd_connection_free(*connection); + *connection = NULL; + failures++; } + fprintf(stderr, "mpd: exhausted retries while waiting for updates\n"); + return false; +} - for (int i = 0; i < dir_count; i++) - free(top_dirs[i]); - free(top_dirs); - mpd_connection_free(c); - return 0; +int main(int argc, char **argv) { + bool wait = true; + bool rescan = false; + const char *path = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--no-wait") == 0) { + wait = false; + } else if (strcmp(argv[i], "--rescan") == 0) { + rescan = true; + } else if (argv[i][0] == '-' && strcmp(argv[i], "-") != 0) { + usage(stderr, argv[0]); + return 2; + } else if (path == NULL) { + path = argv[i]; + } else { + usage(stderr, argv[0]); + return 2; + } + } + + struct mt_strvec paths; + struct mt_strvec directories; + mt_strvec_init(&paths); + mt_strvec_init(&directories); + bool ok = true; + + if (path == NULL && isatty(STDIN_FILENO)) { + ok = mt_strvec_push(&directories, ""); + } else { + bool close_input; + FILE *input = mt_open_input(path, &close_input); + if (input == NULL) + ok = false; + else { + ok = mt_read_lines(input, &paths); + if (close_input && fclose(input) != 0) { + perror("mpd: close input"); + ok = false; + } + if (ok) + ok = top_directories(&paths, &directories); + } + } + if (!ok) { + mt_strvec_clear(&paths); + mt_strvec_clear(&directories); + return 1; + } + if (directories.len == 0U) { + printf("No directories found in input.\n"); + mt_strvec_clear(&paths); + mt_strvec_clear(&directories); + return 0; + } + + struct mpd_connection *connection = NULL; + for (size_t i = 0; i < directories.len; i++) { + if (!submit_directory(&connection, directories.items[i], rescan)) { + ok = false; + break; + } + } + if (ok && wait) { + printf("Waiting for database updates to finish...\n"); + ok = wait_for_updates(&connection); + } + if (ok) + printf("Processed %zu director%s successfully.\n", directories.len, + directories.len == 1U ? "y" : "ies"); + + if (connection != NULL) + mpd_connection_free(connection); + mt_strvec_clear(&paths); + mt_strvec_clear(&directories); + return ok ? 0 : 1; } blob - /dev/null blob + de099c28158f491c076d26d30f3b16747d5a21e8 (mode 644) --- /dev/null +++ mpd/mpd_common.h @@ -0,0 +1,78 @@ +#ifndef MPD_COMMON_H +#define MPD_COMMON_H + +#include +#include +#include +#include + +#define MPD_TOOLS_BATCH_SIZE 512U + +struct mt_strvec { + char **items; + size_t len; + size_t cap; +}; + +struct mt_player_state { + enum mpd_state state; + unsigned queue_version; + unsigned queue_length; + unsigned song_pos; + unsigned song_id; + char *current_uri; +}; + +enum mt_state_match { + MT_STATE_ERROR, + MT_STATE_CHANGED, + MT_STATE_MATCH, +}; + +struct mpd_connection *mt_connect(void); +struct mpd_connection *mt_connect_to(const char *host, unsigned port); +void mt_report_error(const struct mpd_connection *connection, + const char *context); + +void mt_strvec_init(struct mt_strvec *vec); +void mt_strvec_clear(struct mt_strvec *vec); +bool mt_strvec_push(struct mt_strvec *vec, const char *value); +bool mt_strvec_push_n(struct mt_strvec *vec, const char *value, size_t length); +bool mt_read_lines(FILE *input, struct mt_strvec *lines); +FILE *mt_open_input(const char *path, bool *must_close); + +bool mt_add_queue(struct mpd_connection *connection, + const struct mt_strvec *tracks, bool verbose); +bool mt_insert_queue(struct mpd_connection *connection, + const struct mt_strvec *tracks, unsigned position, + bool verbose); +bool mt_add_playlist(struct mpd_connection *connection, const char *playlist, + const struct mt_strvec *tracks, bool verbose); +bool mt_add_queue_stream(struct mpd_connection *connection, FILE *input, + bool verbose, size_t *count); +bool mt_add_playlist_stream(struct mpd_connection *connection, + const char *playlist, FILE *input, bool verbose, + size_t *count); + +bool mt_get_player_state(struct mpd_connection *connection, + struct mt_player_state *state); +void mt_player_state_clear(struct mt_player_state *state); +bool mt_read_queue(struct mpd_connection *connection, + struct mt_strvec *tracks); +bool mt_read_playlist(struct mpd_connection *connection, const char *playlist, + struct mt_strvec *tracks); +enum mt_state_match mt_player_state_is(struct mpd_connection *connection, + unsigned expected_version, + unsigned expected_song_id); +bool mt_replace_queue(struct mpd_connection *connection, + const struct mt_strvec *tracks, + unsigned expected_version, bool force); +bool mt_replace_playlist(struct mpd_connection *connection, + const char *playlist, + const struct mt_strvec *tracks, + const struct mt_strvec *expected, bool force); + +bool mt_edit_lines(struct mt_strvec *lines); +bool mt_parse_size(const char *text, size_t *value); + +#endif blob - 59bc41caf9a3305710d95dfc5b0a0df7c5d3cbb4 blob + 1df4ca3f93719a9bc0602c1c03e3a078e8a70aff --- mpd/mpd_update_queue.c +++ mpd/mpd_update_queue.c @@ -1,139 +1,66 @@ -#include -#include -#include +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include #include -#include #include -#define DEFAULT_HOST "localhost" -#define DEFAULT_PORT 6600 - -struct mpd_connection *conn() { - const char *host = getenv("MPD_HOST"); - if (host == NULL || strlen(host) == 0) host = DEFAULT_HOST; - - unsigned port = DEFAULT_PORT; - const char *port_str = getenv("MPD_PORT"); - if (port_str != NULL && strlen(port_str) > 0) port = (unsigned)atoi(port_str); - - struct mpd_connection *c = mpd_connection_new(host, port, 0); - if (c == NULL) { - fprintf(stderr, "Failed to create MPD connection object\n"); - return NULL; - } - - if (mpd_connection_get_error(c) != MPD_ERROR_SUCCESS) { - fprintf(stderr, "Error connecting to MPD (%s:%u): %s\n", host, port, mpd_connection_get_error_message(c)); - mpd_connection_free(c); - return NULL; - } - return c; +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--force] [FILE|-]\n", program); } -int main(int argc, char *argv[]) { - if (argc < 2) { - fprintf(stderr, "Usage: %s \n", argv[0]); - return 1; +int main(int argc, char **argv) { + bool force = false; + const char *path = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; } - - FILE *input = fopen(argv[1], "r"); - if (input == NULL) { - perror("Error opening file"); - return 1; - } - - struct mpd_connection *c = conn(); - if (c == NULL) { - fclose(input); - return 1; - } - - char **new_tracks = NULL; - int track_count = 0; - char *line = NULL; - size_t len = 0; - ssize_t read_bytes; - char *current_file = NULL; - - /* Read input file first, before querying MPD state */ - while ((read_bytes = getline(&line, &len, input)) != -1) { - if (read_bytes > 0 && line[read_bytes - 1] == '\n') line[read_bytes - 1] = '\0'; - if (read_bytes > 1 && line[read_bytes - 2] == '\r') line[read_bytes - 2] = '\0'; - if (strlen(line) == 0 || line[0] == '#') continue; - - char **temp_tracks = realloc(new_tracks, sizeof(char *) * (track_count + 1)); - if (temp_tracks == NULL) { - perror("realloc"); - goto cleanup; - } - new_tracks = temp_tracks; - - new_tracks[track_count] = strdup(line); - if (new_tracks[track_count] == NULL) { - perror("strdup"); - goto cleanup; - } - track_count++; - } - - /* Re-query MPD state before building command list */ - struct mpd_status *status = mpd_run_status(c); - if (status == NULL) { - fprintf(stderr, "Error getting status: %s\n", mpd_connection_get_error_message(c)); - goto cleanup; - } - enum mpd_state state = mpd_status_get_state(status); - int current_pos = mpd_status_get_song_pos(status); - int total_queue_len = mpd_status_get_queue_length(status); - mpd_status_free(status); - - if (state == MPD_STATE_PLAY || state == MPD_STATE_PAUSE) { - struct mpd_song *song = mpd_run_current_song(c); - if (song != NULL) { - current_file = strdup(mpd_song_get_uri(song)); - mpd_song_free(song); - } - } - - /* Compute new_pos from fresh current_file */ - int new_pos = -1; - for (int i = 0; i < track_count; i++) { - if (current_file && strcmp(new_tracks[i], current_file) == 0 && new_pos == -1) { - new_pos = i; - } - } - - if (!mpd_command_list_begin(c, false)) { - fprintf(stderr, "Failed to start command list: %s\n", mpd_connection_get_error_message(c)); - goto cleanup; - } - - if (state == MPD_STATE_STOP || current_file == NULL || new_pos == -1) { - mpd_send_clear(c); - for (int i = 0; i < track_count; i++) mpd_send_add(c, new_tracks[i]); - if (state != MPD_STATE_STOP) mpd_send_play(c); + if (strcmp(argv[i], "--force") == 0) { + force = true; + } else if (argv[i][0] == '-' && strcmp(argv[i], "-") != 0) { + usage(stderr, argv[0]); + return 2; + } else if (path == NULL) { + path = argv[i]; } else { - for (int i = total_queue_len - 1; i >= 0; i--) { - if (i != current_pos) mpd_send_delete(c, i); - } - for (int i = 0; i < track_count; i++) { - if (i != new_pos) mpd_send_add(c, new_tracks[i]); - } - if (new_pos > 0) mpd_send_move(c, 0, new_pos); + usage(stderr, argv[0]); + return 2; } + } - if (!mpd_command_list_end(c) || !mpd_response_finish(c)) { - fprintf(stderr, "Failed to execute command list: %s\n", mpd_connection_get_error_message(c)); - } else { - printf("Queue updated successfully.\n"); - } + bool close_input; + FILE *input = mt_open_input(path, &close_input); + if (input == NULL) + return 1; + if (path == NULL && isatty(STDIN_FILENO)) + fprintf(stderr, + "Reading the new queue from stdin; press Ctrl-D to finish.\n"); -cleanup: - free(line); - fclose(input); - if (current_file) free(current_file); - for (int i = 0; i < track_count; i++) free(new_tracks[i]); - free(new_tracks); - mpd_connection_free(c); - return 0; + struct mt_strvec tracks; + mt_strvec_init(&tracks); + bool ok = mt_read_lines(input, &tracks); + if (close_input && fclose(input) != 0) { + perror("mpd: close input"); + ok = false; + } + if (!ok) { + mt_strvec_clear(&tracks); + return 1; + } + + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) { + mt_strvec_clear(&tracks); + return 1; + } + ok = mt_replace_queue(connection, &tracks, UINT_MAX, force); + if (ok) + printf("Queue replaced with %zu track%s.\n", tracks.len, + tracks.len == 1U ? "" : "s"); + mpd_connection_free(connection); + mt_strvec_clear(&tracks); + return ok ? 0 : 1; } blob - /dev/null blob + 3d6f2494fd3febf153765f1c122707de6e402103 (mode 644) --- /dev/null +++ mpd/mpd_edit_playlist.c @@ -0,0 +1,69 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include + +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--force] PLAYLIST\n", program); +} + +int main(int argc, char **argv) { + bool force = false; + const char *playlist = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--force") == 0) { + force = true; + } else if (argv[i][0] == '-') { + usage(stderr, argv[0]); + return 2; + } else if (playlist == NULL) { + playlist = argv[i]; + } else { + usage(stderr, argv[0]); + return 2; + } + } + if (playlist == NULL) { + usage(stderr, argv[0]); + return 2; + } + + struct mt_strvec original; + struct mt_strvec edited; + mt_strvec_init(&original); + mt_strvec_init(&edited); + + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) + return 1; + bool ok = mt_read_playlist(connection, playlist, &original); + mpd_connection_free(connection); + for (size_t i = 0; ok && i < original.len; i++) + ok = mt_strvec_push(&edited, original.items[i]); + if (!ok && edited.len != original.len) + perror("mpd: copy stored playlist"); + + if (ok) + ok = mt_edit_lines(&edited); + if (ok) { + connection = mt_connect(); + if (connection == NULL) { + ok = false; + } else { + ok = mt_replace_playlist(connection, playlist, &edited, &original, force); + mpd_connection_free(connection); + } + } + if (ok) + printf("Playlist %s replaced with %zu track%s.\n", playlist, edited.len, + edited.len == 1U ? "" : "s"); + + mt_strvec_clear(&original); + mt_strvec_clear(&edited); + return ok ? 0 : 1; +} blob - /dev/null blob + 1de49c69c865e2418222cc96412f8b6a2242553d (mode 644) --- /dev/null +++ mpd/mpd_insert_next.c @@ -0,0 +1,91 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include +#include +#include + +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--verbose] [FILE|-]\n", program); +} + +int main(int argc, char **argv) { + bool verbose = false; + const char *path = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--verbose") == 0) { + verbose = true; + } else if (argv[i][0] == '-' && strcmp(argv[i], "-") != 0) { + usage(stderr, argv[0]); + return 2; + } else if (path == NULL) { + path = argv[i]; + } else { + usage(stderr, argv[0]); + return 2; + } + } + + bool close_input; + FILE *input = mt_open_input(path, &close_input); + if (input == NULL) + return 1; + if (path == NULL && isatty(STDIN_FILENO)) + fprintf(stderr, "Reading tracks from stdin; press Ctrl-D to finish.\n"); + + struct mt_strvec tracks; + mt_strvec_init(&tracks); + bool ok = mt_read_lines(input, &tracks); + if (close_input && fclose(input) != 0) { + perror("mpd: close input"); + ok = false; + } + if (!ok) { + mt_strvec_clear(&tracks); + return 1; + } + + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) { + mt_strvec_clear(&tracks); + return 1; + } + struct mt_player_state state; + ok = mt_get_player_state(connection, &state); + unsigned position = 0U; + if (ok && state.song_pos != UINT_MAX) { + if (state.song_pos == UINT_MAX - 1U) { + fprintf(stderr, "mpd: current queue position is too large\n"); + ok = false; + } else { + position = state.song_pos + 1U; + } + } + if (ok) { + enum mt_state_match match = + mt_player_state_is(connection, state.queue_version, state.song_id); + if (match == MT_STATE_CHANGED) { + fprintf(stderr, + "mpd: player or queue changed before insertion; retry the " + "command\n"); + ok = false; + } else if (match == MT_STATE_ERROR) { + ok = false; + } + } + if (ok) + ok = mt_insert_queue(connection, &tracks, position, verbose); + if (ok) + printf("Inserted %zu track%s at queue position %u.\n", tracks.len, + tracks.len == 1U ? "" : "s", position); + + mt_player_state_clear(&state); + mpd_connection_free(connection); + mt_strvec_clear(&tracks); + return ok ? 0 : 1; +} blob - /dev/null blob + 724f19a4fbf4447f416030a79b91d823ceccdfa6 (mode 644) --- /dev/null +++ mpd/mpd_now_playing.c @@ -0,0 +1,234 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include +#include +#include +#include + +static volatile sig_atomic_t received_signal; + +static void handle_signal(int signal_number) { + received_signal = signal_number; +} + +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--watch] [--json]\n", program); +} + +static const char *state_name(enum mpd_state state) { + switch (state) { + case MPD_STATE_STOP: + return "stopped"; + case MPD_STATE_PLAY: + return "playing"; + case MPD_STATE_PAUSE: + return "paused"; + case MPD_STATE_UNKNOWN: + default: + return "unknown"; + } +} + +static void print_json_string(const char *value) { + if (value == NULL) { + fputs("null", stdout); + return; + } + putchar('"'); + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor != '\0'; cursor++) { + switch (*cursor) { + case '"': + fputs("\\\"", stdout); + break; + case '\\': + fputs("\\\\", stdout); + break; + case '\b': + fputs("\\b", stdout); + break; + case '\f': + fputs("\\f", stdout); + break; + case '\n': + fputs("\\n", stdout); + break; + case '\r': + fputs("\\r", stdout); + break; + case '\t': + fputs("\\t", stdout); + break; + default: + if (*cursor < 0x20U) + printf("\\u%04x", (unsigned)*cursor); + else + putchar((int)*cursor); + } + } + putchar('"'); +} + +static bool emit_snapshot(struct mpd_connection *connection, bool json) { + struct mpd_status *status = mpd_run_status(connection); + if (status == NULL) { + mt_report_error(connection, "get now-playing status"); + return false; + } + enum mpd_state state = mpd_status_get_state(status); + int position = mpd_status_get_song_pos(status); + int song_id = mpd_status_get_song_id(status); + unsigned elapsed_ms = mpd_status_get_elapsed_ms(status); + unsigned duration = mpd_status_get_total_time(status); + mpd_status_free(status); + + struct mpd_song *song = mpd_run_current_song(connection); + if (song == NULL && + mpd_connection_get_error(connection) != MPD_ERROR_SUCCESS) { + mt_report_error(connection, "get now-playing song"); + return false; + } + + const char *artist = + song == NULL ? NULL : mpd_song_get_tag(song, MPD_TAG_ARTIST, 0U); + const char *album_artist = + song == NULL ? NULL : mpd_song_get_tag(song, MPD_TAG_ALBUM_ARTIST, 0U); + const char *album = + song == NULL ? NULL : mpd_song_get_tag(song, MPD_TAG_ALBUM, 0U); + const char *title = + song == NULL ? NULL : mpd_song_get_tag(song, MPD_TAG_TITLE, 0U); + const char *uri = song == NULL ? NULL : mpd_song_get_uri(song); + + if (json) { + fputs("{\"state\":", stdout); + print_json_string(state_name(state)); + fputs(",\"artist\":", stdout); + print_json_string(artist); + fputs(",\"album_artist\":", stdout); + print_json_string(album_artist); + fputs(",\"album\":", stdout); + print_json_string(album); + fputs(",\"title\":", stdout); + print_json_string(title); + fputs(",\"uri\":", stdout); + print_json_string(uri); + fputs(",\"elapsed_seconds\":", stdout); + if (song_id < 0) + fputs("null", stdout); + else + printf("%.3f", (double)elapsed_ms / 1000.0); + fputs(",\"duration_seconds\":", stdout); + if (song_id < 0 || duration == 0U) + fputs("null", stdout); + else + printf("%u", duration); + fputs(",\"queue_position\":", stdout); + if (position < 0) + fputs("null", stdout); + else + printf("%d", position); + fputs(",\"song_id\":", stdout); + if (song_id < 0) + fputs("null", stdout); + else + printf("%d", song_id); + fputs("}\n", stdout); + } else if (artist != NULL && title != NULL) { + printf("%s - %s\n", artist, title); + } else if (title != NULL) { + printf("%s\n", title); + } else if (uri != NULL) { + printf("%s\n", uri); + } else { + printf("%s\n", state_name(state)); + } + + if (song != NULL) + mpd_song_free(song); + if (fflush(stdout) != 0) { + perror("mpd: flush now-playing output"); + return false; + } + return true; +} + +static void reconnect_delay(unsigned failures) { + unsigned milliseconds = failures < 5U ? 250U << failures : 4000U; + if (milliseconds > 4000U) + milliseconds = 4000U; + struct timespec delay = {.tv_sec = (time_t)(milliseconds / 1000U), + .tv_nsec = (long)(milliseconds % 1000U) * 1000000L}; + while (received_signal == 0 && nanosleep(&delay, &delay) < 0 && + errno == EINTR) { + } +} + +int main(int argc, char **argv) { + bool watch = false; + bool json = false; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--watch") == 0) { + watch = true; + } else if (strcmp(argv[i], "--json") == 0) { + json = true; + } else { + usage(stderr, argv[0]); + return 2; + } + } + + if (!watch) { + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) + return 1; + bool ok = emit_snapshot(connection, json); + mpd_connection_free(connection); + return ok ? 0 : 1; + } + + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = handle_signal; + sigemptyset(&action.sa_mask); + if (sigaction(SIGINT, &action, NULL) != 0 || + sigaction(SIGTERM, &action, NULL) != 0) { + perror("mpd: install signal handlers"); + return 1; + } + + unsigned failures = 0U; + while (received_signal == 0) { + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) { + reconnect_delay(failures++); + continue; + } + if (!emit_snapshot(connection, json)) { + mpd_connection_free(connection); + reconnect_delay(failures++); + continue; + } + failures = 0U; + + while (received_signal == 0) { + enum mpd_idle events = + mpd_run_idle_mask(connection, MPD_IDLE_PLAYER | MPD_IDLE_QUEUE); + if (received_signal != 0) + break; + if ((events & (MPD_IDLE_PLAYER | MPD_IDLE_QUEUE)) == 0) + break; + if (!emit_snapshot(connection, json)) + break; + } + mpd_connection_free(connection); + if (received_signal == 0) + reconnect_delay(failures++); + } + return 128 + (int)received_signal; +} blob - /dev/null blob + 21ea049c9fd2c480673e6ec649db75d6efa1aab3 (mode 644) --- /dev/null +++ mpd/mpd_report.c @@ -0,0 +1,398 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct window { + time_t start; + time_t end; + bool has_start; + bool has_end; +}; + +struct options { + const char *host; + const char *path; + const char *tracks_output; + unsigned port; + bool include_files; + bool indexed; + bool relative; + struct window *windows; + size_t window_count; +}; + +struct atomic_file { + FILE *stream; + char *temporary_path; + const char *final_path; +}; + +static void usage(FILE *stream, const char *program) { + fprintf( + stream, + "Usage: %s [OPTIONS]\n" + "Query MPD's database for directories modified in one or more time " + "windows.\n\n" + " -w, --window START,END epoch-second bounds; '-' means unbounded\n" + " -f, --files include songs as well as directories\n" + " -i, --indexed prefix results with their window index\n" + " -r, --relative strip the requested MPD path prefix\n" + " -p, --path URI restrict the lookup to an MPD URI\n" + " --tracks-output FILE atomically write every song URI to FILE\n" + " -H, --host HOST MPD host (default: $MPD_HOST or localhost)\n" + " -P, --port PORT MPD port (default: $MPD_PORT or 6600)\n" + " -h, --help show this help\n\n" + "One window writes plain URIs. Multiple windows (or --indexed) write\n" + "WINDOW_INDEXURI. With no --window, the default is all time.\n" + "Date windows use exclusive bounds, like diggah's filesystem backend.\n", + program); +} + +static bool parse_unsigned(const char *text, unsigned *value) { + char *end = NULL; + errno = 0; + unsigned long parsed = strtoul(text, &end, 10); + if (errno != 0 || end == text || *end != '\0' || parsed > UINT_MAX) + return false; + *value = (unsigned)parsed; + return true; +} + +static bool parse_timestamp(const char *text, time_t *value) { + char *end = NULL; + errno = 0; + intmax_t parsed = strtoimax(text, &end, 10); + if (errno != 0 || end == text || *end != '\0') + return false; + + time_t timestamp = (time_t)parsed; + if ((intmax_t)timestamp != parsed) + return false; + *value = timestamp; + return true; +} + +static bool append_window(struct options *options, const char *argument) { + const char *comma = strchr(argument, ','); + if (comma == NULL || strchr(comma + 1, ',') != NULL) { + fprintf(stderr, "invalid window '%s' (expected START,END)\n", argument); + return false; + } + + size_t start_length = (size_t)(comma - argument); + char *start = strndup(argument, start_length); + char *end = strdup(comma + 1); + if (start == NULL || end == NULL) { + perror("allocating window"); + free(start); + free(end); + return false; + } + + struct window window = {0}; + window.has_start = strcmp(start, "-") != 0; + window.has_end = strcmp(end, "-") != 0; + bool valid = true; + if (window.has_start && !parse_timestamp(start, &window.start)) + valid = false; + if (window.has_end && !parse_timestamp(end, &window.end)) + valid = false; + if (window.has_start && window.has_end && window.start >= window.end) + valid = false; + free(start); + free(end); + + if (!valid) { + fprintf(stderr, "invalid window '%s'\n", argument); + return false; + } + + struct window *windows = + realloc(options->windows, + (options->window_count + 1U) * sizeof(*options->windows)); + if (windows == NULL) { + perror("allocating windows"); + return false; + } + options->windows = windows; + options->windows[options->window_count++] = window; + return true; +} + +static bool parse_options(int argc, char **argv, struct options *options) { + *options = (struct options){.path = ""}; + + enum { OPT_TRACKS_OUTPUT = 256 }; + static const struct option long_options[] = { + {"window", required_argument, NULL, 'w'}, + {"files", no_argument, NULL, 'f'}, + {"indexed", no_argument, NULL, 'i'}, + {"relative", no_argument, NULL, 'r'}, + {"path", required_argument, NULL, 'p'}, + {"tracks-output", required_argument, NULL, OPT_TRACKS_OUTPUT}, + {"host", required_argument, NULL, 'H'}, + {"port", required_argument, NULL, 'P'}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0}, + }; + + int option = 0; + while ((option = getopt_long(argc, argv, "w:firp:H:P:h", long_options, + NULL)) != -1) { + switch (option) { + case 'w': + if (!append_window(options, optarg)) + return false; + break; + case 'f': + options->include_files = true; + break; + case 'i': + options->indexed = true; + break; + case 'r': + options->relative = true; + break; + case 'p': + options->path = optarg; + break; + case 'H': + options->host = optarg; + break; + case 'P': + if (!parse_unsigned(optarg, &options->port) || options->port == 0U || + options->port > 65535U) { + fprintf(stderr, "invalid MPD port '%s'\n", optarg); + return false; + } + break; + case OPT_TRACKS_OUTPUT: + options->tracks_output = optarg; + break; + case 'h': + usage(stdout, argv[0]); + exit(0); + default: + usage(stderr, argv[0]); + return false; + } + } + + if (optind != argc) { + fprintf(stderr, "unexpected argument '%s'\n", argv[optind]); + return false; + } + if (options->window_count == 0U && !append_window(options, "-,-")) + return false; + return true; +} + +static struct mpd_connection *connect_mpd(const struct options *options) { + return mt_connect_to(options->host, options->port); +} + +static bool atomic_file_open(struct atomic_file *file, const char *path) { + *file = (struct atomic_file){.final_path = path}; + size_t length = strlen(path) + sizeof(".tmp.XXXXXX"); + file->temporary_path = malloc(length); + if (file->temporary_path == NULL) { + perror("allocating temporary path"); + return false; + } + snprintf(file->temporary_path, length, "%s.tmp.XXXXXX", path); + + int descriptor = mkstemp(file->temporary_path); + if (descriptor < 0) { + fprintf(stderr, "opening temporary track list for '%s': %s\n", path, + strerror(errno)); + free(file->temporary_path); + file->temporary_path = NULL; + return false; + } + file->stream = fdopen(descriptor, "w"); + if (file->stream == NULL) { + int error = errno; + close(descriptor); + unlink(file->temporary_path); + errno = error; + perror("opening temporary track list stream"); + free(file->temporary_path); + file->temporary_path = NULL; + return false; + } + return true; +} + +static void atomic_file_abort(struct atomic_file *file) { + if (file->stream != NULL) + fclose(file->stream); + if (file->temporary_path != NULL) { + unlink(file->temporary_path); + free(file->temporary_path); + } + *file = (struct atomic_file){0}; +} + +static bool atomic_file_commit(struct atomic_file *file) { + bool success = true; + int write_error = 0; + if (fflush(file->stream) != 0) + write_error = errno; + if (fsync(fileno(file->stream)) != 0 && write_error == 0) + write_error = errno; + if (fclose(file->stream) != 0 && write_error == 0) + write_error = errno; + if (write_error != 0) { + fprintf(stderr, "writing track list '%s': %s\n", file->final_path, + strerror(write_error)); + success = false; + } + file->stream = NULL; + if (success && rename(file->temporary_path, file->final_path) != 0) { + fprintf(stderr, "installing track list '%s': %s\n", file->final_path, + strerror(errno)); + success = false; + } + if (!success) + unlink(file->temporary_path); + free(file->temporary_path); + file->temporary_path = NULL; + return success; +} + +static bool in_window(time_t modified, const struct window *window) { + if ((window->has_start || window->has_end) && modified == (time_t)0) + return false; + if (window->has_start && modified <= window->start) + return false; + if (window->has_end && modified >= window->end) + return false; + return true; +} + +static const char *relative_uri(const char *uri, + const struct options *options) { + if (!options->relative || options->path[0] == '\0') + return uri; + + size_t prefix_length = strlen(options->path); + while (prefix_length > 0U && options->path[prefix_length - 1U] == '/') + prefix_length--; + if (strncmp(uri, options->path, prefix_length) != 0) + return uri; + if (uri[prefix_length] == '/') + return uri + prefix_length + 1U; + if (uri[prefix_length] == '\0') + return "."; + return uri; +} + +static bool emit_matches(const struct options *options, const char *uri, + time_t modified) { + const char *output_uri = relative_uri(uri, options); + for (size_t index = 0; index < options->window_count; index++) { + if (!in_window(modified, &options->windows[index])) + continue; + int written; + if (options->indexed || options->window_count > 1U) + written = printf("%zu\t%s\n", index, output_uri); + else + written = printf("%s\n", output_uri); + if (written < 0) + return false; + } + return true; +} + +static bool query(const struct options *options) { + struct mpd_connection *connection = connect_mpd(options); + if (connection == NULL) + return false; + + struct atomic_file tracks = {0}; + if (options->tracks_output != NULL && + !atomic_file_open(&tracks, options->tracks_output)) { + mpd_connection_free(connection); + return false; + } + + bool success = mpd_send_list_all_meta(connection, options->path); + if (!success) { + fprintf(stderr, "requesting MPD database: %s\n", + mpd_connection_get_error_message(connection)); + } + + struct mpd_entity *entity = NULL; + while (success && (entity = mpd_recv_entity(connection)) != NULL) { + enum mpd_entity_type type = mpd_entity_get_type(entity); + const char *uri = NULL; + time_t modified = 0; + + if (type == MPD_ENTITY_TYPE_DIRECTORY) { + const struct mpd_directory *directory = mpd_entity_get_directory(entity); + uri = mpd_directory_get_path(directory); + modified = mpd_directory_get_last_modified(directory); + } else if (type == MPD_ENTITY_TYPE_SONG) { + const struct mpd_song *song = mpd_entity_get_song(entity); + uri = mpd_song_get_uri(song); + modified = mpd_song_get_last_modified(song); + if (tracks.stream != NULL && fprintf(tracks.stream, "%s\n", uri) < 0) { + fprintf(stderr, "writing track list '%s': %s\n", tracks.final_path, + strerror(errno)); + success = false; + } + if (!options->include_files) + uri = NULL; + } + + if (success && uri != NULL && !emit_matches(options, uri, modified)) { + perror("writing results"); + success = false; + } + mpd_entity_free(entity); + } + + if (success && !mpd_response_finish(connection)) { + fprintf(stderr, "reading MPD database: %s\n", + mpd_connection_get_error_message(connection)); + success = false; + } + if (success && fflush(stdout) != 0) { + perror("writing results"); + success = false; + } + + if (tracks.stream != NULL) { + if (success) + success = atomic_file_commit(&tracks); + else + atomic_file_abort(&tracks); + } + mpd_connection_free(connection); + return success; +} + +int main(int argc, char **argv) { + struct options options; + if (!parse_options(argc, argv, &options)) { + free(options.windows); + return 2; + } + + bool success = query(&options); + free(options.windows); + return success ? 0 : 1; +} blob - /dev/null blob + c0f67d5514b70bf566ae9d403c65451e1d4e6ec7 (mode 755) --- /dev/null +++ mpd/mpd_report_monthly @@ -0,0 +1,93 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "date" +require "open3" +require "optparse" +require "tempfile" + +REPORT_COUNT = 4 +MANAGED_MPD_OPTIONS = %w[-i --indexed -w --window].freeze + +def main(arguments) + options = {output_directory: "."} + parser = OptionParser.new do |opts| + opts.banner = "Usage: #{opts.program_name} [-o DIRECTORY] MONTH [YEAR] [-- MPD_OPTION ...]" + opts.on("-o", "--output-dir DIRECTORY", "write reports beneath DIRECTORY") do |directory| + options[:output_directory] = directory + end + opts.on("-h", "--help", "show this help") do + puts opts + return 0 + end + end + + separator = arguments.index("--") + mpd_options = separator ? arguments.slice!((separator + 1)..) : [] + arguments.pop if arguments.last == "--" + parser.parse!(arguments) + + month = Integer(arguments.shift || raise(OptionParser::MissingArgument, "MONTH"), 10) + year = Integer(arguments.shift || Date.today.year, 10) + raise OptionParser::InvalidArgument, arguments.first unless arguments.empty? + + first = Date.new(year, month, 1) + output_directory = options[:output_directory] + raise OptionParser::InvalidArgument, "not a directory: #{output_directory}" unless Dir.exist?(output_directory) + + reserved = mpd_options.find do |option| + MANAGED_MPD_OPTIONS.include?(option) || option.start_with?("--window=") + end + raise OptionParser::InvalidArgument, "#{reserved} is managed by this helper" if reserved + + command = ENV["MPD_REPORT"] || begin + sibling = File.join(__dir__, "mpd_report") + File.executable?(sibling) ? sibling : "mpd_report" + end + + windows = REPORT_COUNT.times.flat_map do |week| + dates = [first + week * 7, first + (week + 1) * 7] + epochs = dates.map { |date| Time.local(date.year, date.month, date.day).to_i } + ["--window", epochs.join(",")] + end + + stdout, stderr, status = Open3.capture3(command, *mpd_options, *windows) + warn stderr unless stderr.empty? + return status.exitstatus || 1 unless status.success? + + reports = Array.new(REPORT_COUNT) { [] } + stdout.each_line do |line| + index, uri = line.chomp.split("\t", 2) + unless index&.match?(/\A[0-3]\z/) && uri + warn "#{parser.program_name}: malformed indexed output: #{line.chomp}" + return 1 + end + reports[index.to_i] << uri + end + + reports.each_with_index do |uris, index| + name = format("%d_%02d_%04d.txt", index + 1, month, year) + target = File.join(output_directory, name) + Tempfile.create([".#{name}.", ".tmp"], output_directory) do |file| + uris.each { |uri| file.puts(uri) } + file.flush + file.fsync + file.close + File.rename(file.path, target) + end + puts target + end + 0 +rescue OptionParser::ParseError, ArgumentError => error + warn "#{parser.program_name}: #{error.message}" + warn parser + 2 +rescue Errno::ENOENT => error + warn "#{parser.program_name}: #{error.message}" + 127 +rescue SystemCallError => error + warn "#{parser.program_name}: #{error.message}" + 1 +end + +exit main(ARGV) blob - /dev/null blob + c174881c601c9bf7943eb787c8359f9d15648e11 (mode 644) --- /dev/null +++ mpd/mpd_trim_queue.c @@ -0,0 +1,59 @@ +#define _POSIX_C_SOURCE 200809L + +#include "mpd_common.h" + +#include +#include + +static void usage(FILE *file, const char *program) { + fprintf(file, "Usage: %s [--keep N]\n", program); +} + +int main(int argc, char **argv) { + size_t keep = 0U; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(stdout, argv[0]); + return 0; + } + if (strcmp(argv[i], "--keep") == 0 && i + 1 < argc) { + if (!mt_parse_size(argv[++i], &keep) || keep > UINT_MAX) { + fprintf(stderr, "mpd: invalid --keep value\n"); + return 2; + } + } else { + usage(stderr, argv[0]); + return 2; + } + } + + struct mpd_connection *connection = mt_connect(); + if (connection == NULL) + return 1; + struct mt_player_state state; + bool ok = mt_get_player_state(connection, &state); + unsigned remove = 0U; + if (ok && state.song_pos != UINT_MAX && state.song_pos > (unsigned)keep) + remove = state.song_pos - (unsigned)keep; + if (ok && remove > 0U) { + enum mt_state_match match = + mt_player_state_is(connection, state.queue_version, state.song_id); + if (match == MT_STATE_CHANGED) { + fprintf(stderr, "mpd: player or queue changed before trimming; retry the " + "command\n"); + ok = false; + } else if (match == MT_STATE_ERROR) { + ok = false; + } + } + if (ok && remove > 0U && !mpd_run_delete_range(connection, 0U, remove)) { + mt_report_error(connection, "trim played tracks"); + ok = false; + } + if (ok) + printf("Removed %u played track%s.\n", remove, remove == 1U ? "" : "s"); + + mt_player_state_clear(&state); + mpd_connection_free(connection); + return ok ? 0 : 1; +} blob - /dev/null blob + 227a6851c9c4f9a6419b0ccc3bb7e821882d1799 (mode 644) --- /dev/null +++ mpd/tests/test_mpd_report.py @@ -0,0 +1,149 @@ +import datetime +import os +import socketserver +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path + +BIN_OVERRIDE = os.environ.get("MPD_TEST_BIN") + +MPD_RESPONSE = ( + "directory: Artist\n" + "Last-Modified: 2026-08-07T10:00:00Z\n" + "directory: Artist/Album\n" + "Last-Modified: 2026-08-08T10:00:00Z\n" + "file: Artist/Album/track.flac\n" + "Last-Modified: 2026-08-08T11:00:00Z\n" + "Time: 60\n" + "duration: 60.0\n" +) + + +class MpdHandler(socketserver.StreamRequestHandler): + def handle(self): + self.wfile.write(b"OK MPD 0.23.0\n") + self.wfile.flush() + while raw := self.rfile.readline(): + command = raw.decode().rstrip("\r\n") + self.server.commands.append(command) + if command.startswith("listallinfo "): + self.wfile.write(MPD_RESPONSE.encode()) + self.wfile.write(b"OK\n") + else: + name = command.split(maxsplit=1)[0] + self.wfile.write( + f"ACK [5@0] {{{name}}} unsupported test command\n".encode() + ) + self.wfile.flush() + + +class MpdServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self): + self.commands = [] + super().__init__(("127.0.0.1", 0), MpdHandler) + + +def epoch(day): + value = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.UTC) + return int(value.timestamp()) + + +class MpdReportTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if BIN_OVERRIDE is not None: + cls.temporary_directory = None + cls.binary = Path(BIN_OVERRIDE) / "mpd_report" + return + cls.temporary_directory = tempfile.TemporaryDirectory() + cls.binary = Path(cls.temporary_directory.name) / "mpd_report" + source = Path(__file__).resolve().parents[1] / "mpd_report.c" + common = Path(__file__).resolve().parents[1] / "mpd_common.c" + subprocess.run( + [ + "cc", + "-std=c17", + "-D_POSIX_C_SOURCE=200809L", + "-Wall", + "-Wextra", + "-Wconversion", + "-Wstrict-prototypes", + "-o", + str(cls.binary), + str(source), + str(common), + "-lmpdclient", + ], + check=True, + ) + + @classmethod + def tearDownClass(cls): + if cls.temporary_directory is not None: + cls.temporary_directory.cleanup() + + def setUp(self): + self.server = MpdServer() + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + + def run_tool(self, *arguments): + environment = os.environ.copy() + environment.update( + MPD_HOST="127.0.0.1", + MPD_PORT=str(self.server.server_address[1]), + MPD_CONFIG_HELPER="off", + ) + return subprocess.run( + [str(self.binary), *arguments], + text=True, + capture_output=True, + env=environment, + timeout=5, + ) + + def test_all_time_writes_plain_directories_and_tracks(self): + with tempfile.TemporaryDirectory() as directory: + tracks = Path(directory) / "all_tracks.txt" + result = self.run_tool("--tracks-output", str(tracks)) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "Artist\nArtist/Album\n") + self.assertEqual(tracks.read_text(), "Artist/Album/track.flac\n") + self.assertEqual(self.server.commands, ['listallinfo ""']) + + def test_multiple_windows_are_indexed_and_can_include_files(self): + first = f"{epoch('2026-08-07')},{epoch('2026-08-08')}" + second = f"{epoch('2026-08-08')},{epoch('2026-08-09')}" + result = self.run_tool("--files", "--window", first, "--window", second) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout, + "0\tArtist\n" "1\tArtist/Album\n" "1\tArtist/Album/track.flac\n", + ) + + def test_overlapping_windows_emit_each_matching_index(self): + broad = f"{epoch('2026-08-07')},{epoch('2026-08-09')}" + narrow = f"{epoch('2026-08-08')},{epoch('2026-08-09')}" + result = self.run_tool("--window", broad, "--window", narrow) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout, + "0\tArtist\n" "0\tArtist/Album\n" "1\tArtist/Album\n", + ) + + +if __name__ == "__main__": + unittest.main() blob - /dev/null blob + a3faac462834a8fb55c3f613628d24e441484578 (mode 644) --- /dev/null +++ mpd/tests/test_mpd_report_monthly.py @@ -0,0 +1,107 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +HELPER = Path(__file__).resolve().parents[1] / "mpd_report_monthly" + + +class MpdReportMonthlyTest(unittest.TestCase): + def make_fake(self, directory: Path, body: str) -> Path: + fake = directory / "mpd_report" + fake.write_text("#!/bin/sh\nset -eu\n" + body) + fake.chmod(0o755) + return fake + + def run_helper(self, directory: Path, fake: Path, *arguments: str): + environment = os.environ.copy() + environment.update(MPD_REPORT=str(fake), TZ="UTC") + return subprocess.run( + [str(HELPER), "--output-dir", str(directory), *arguments], + text=True, + capture_output=True, + env=environment, + timeout=5, + ) + + def test_splits_indexed_results_and_builds_expected_windows(self): + with tempfile.TemporaryDirectory() as name: + directory = Path(name) + arguments_file = directory / "arguments" + fake = self.make_fake( + directory, + 'printf "%s\\n" "$@" > "$ARGUMENTS_FILE"\n' + "printf '0\\tArtist\\n1\\tArtist/Album\\n3\\ttrack.flac\\n'\n", + ) + environment = os.environ.copy() + environment.update( + MPD_REPORT=str(fake), ARGUMENTS_FILE=str(arguments_file), TZ="UTC" + ) + result = subprocess.run( + [ + str(HELPER), + "--output-dir", + str(directory), + "7", + "2026", + "--", + "--files", + ], + text=True, + capture_output=True, + env=environment, + timeout=5, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((directory / "1_07_2026.txt").read_text(), "Artist\n") + self.assertEqual( + (directory / "2_07_2026.txt").read_text(), "Artist/Album\n" + ) + self.assertEqual((directory / "3_07_2026.txt").read_text(), "") + self.assertEqual((directory / "4_07_2026.txt").read_text(), "track.flac\n") + self.assertEqual( + arguments_file.read_text().splitlines(), + [ + "--files", + "--window", + "1782864000,1783468800", + "--window", + "1783468800,1784073600", + "--window", + "1784073600,1784678400", + "--window", + "1784678400,1785283200", + ], + ) + + def test_failed_query_does_not_replace_existing_reports(self): + with tempfile.TemporaryDirectory() as name: + directory = Path(name) + report = directory / "1_07_2026.txt" + report.write_text("keep me\n") + fake = self.make_fake(directory, "printf '0\\tpartial\\n'\nexit 1\n") + + result = self.run_helper(directory, fake, "07", "2026") + + self.assertEqual(result.returncode, 1) + self.assertEqual(report.read_text(), "keep me\n") + self.assertFalse((directory / "2_07_2026.txt").exists()) + + def test_rejects_windows_owned_by_helper(self): + with tempfile.TemporaryDirectory() as name: + directory = Path(name) + fake = self.make_fake(directory, "exit 0\n") + + result = self.run_helper( + directory, fake, "07", "2026", "--", "--window", "1,2" + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("managed by this helper", result.stderr) + + +if __name__ == "__main__": + unittest.main() blob - /dev/null blob + c2fbb26e67987d182d86ff1049ef76170caaab8d (mode 644) --- /dev/null +++ mpd/tests/test_mpd_tools.py @@ -0,0 +1,620 @@ +import json +import os +import shlex +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN_OVERRIDE = os.environ.get("MPD_TEST_BIN") +BIN = Path(BIN_OVERRIDE) if BIN_OVERRIDE else ROOT / "bin" +TOOLS = ( + "mpd_add_to_playlist", + "mpd_add_to_queue", + "mpd_edit_queue", + "mpd_update_library", + "mpd_update_queue", + "mpd_insert_next", + "mpd_edit_playlist", + "mpd_now_playing", + "mpd_trim_queue", + "mpd_report", +) + + +class MPDState: + def __init__(self): + self.lock = threading.Lock() + self.queue = [] + self.playlists = {} + self.next_id = 1 + self.version = 1 + self.state = "stop" + self.current = None + self.commands = [] + self.updating = False + self.advance_on_current_song = False + + def add_song(self, uri, position=None): + song = {"uri": uri, "id": self.next_id} + self.next_id += 1 + if position is None or position >= len(self.queue): + self.queue.append(song) + else: + self.queue.insert(position, song) + if self.current is not None and position <= self.current: + self.current += 1 + self.version += 1 + return song + + def response(self, raw): + args = shlex.split(raw) + if not args: + return [] + command = args[0] + self.commands.append((command, args[1:])) + + if command == "status": + lines = [ + "volume: -1", + "repeat: 0", + "random: 0", + "single: 0", + "consume: 0", + f"playlist: {self.version}", + f"playlistlength: {len(self.queue)}", + f"state: {self.state}", + ] + if self.current is not None and self.current < len(self.queue): + song = self.queue[self.current] + lines += [ + f"song: {self.current}", + f"songid: {song['id']}", + "elapsed: 12.500", + "duration: 180.000", + ] + if self.updating: + lines.append("updating_db: 1") + return lines + + if command == "currentsong": + if self.advance_on_current_song: + self.advance_on_current_song = False + if self.current is not None and self.current + 1 < len(self.queue): + self.current += 1 + if self.current is None or self.current >= len(self.queue): + return [] + song = self.queue[self.current] + return [ + f"file: {song['uri']}", + 'Artist: Artist "quoted"', + "AlbumArtist: Album Artist", + "Album: Test Album", + "Title: Test Title", + "Time: 180", + "duration: 180.000", + f"Pos: {self.current}", + f"Id: {song['id']}", + ] + + if command == "playlistinfo": + lines = [] + for position, song in enumerate(self.queue): + lines += [ + f"file: {song['uri']}", + "Time: 180", + f"Pos: {position}", + f"Id: {song['id']}", + ] + return lines + + if command == "listplaylist": + return [f"file: {uri}" for uri in self.playlists.get(args[1], [])] + + if command in ("add", "addid"): + uri = args[1] + if uri == "FAIL": + raise ValueError("No such song") + position = int(args[2]) if command == "addid" and len(args) > 2 else None + song = self.add_song(uri, position) + return [f"Id: {song['id']}"] if command == "addid" else [] + + if command == "delete": + spec = args[1] + if ":" in spec: + start_text, end_text = spec.split(":", 1) + start = int(start_text) + end = len(self.queue) if not end_text else int(end_text) + else: + start = int(spec) + end = start + 1 + removed = end - start + del self.queue[start:end] + if self.current is not None: + if start <= self.current < end: + self.current = None + self.state = "stop" + elif self.current >= end: + self.current -= removed + self.version += 1 + return [] + + if command == "move": + source, destination = int(args[1]), int(args[2]) + song = self.queue.pop(source) + self.queue.insert(destination, song) + if self.current == source: + self.current = destination + elif self.current is not None and source < self.current <= destination: + self.current -= 1 + elif self.current is not None and destination <= self.current < source: + self.current += 1 + self.version += 1 + return [] + + if command == "clear": + self.queue.clear() + self.current = None + self.state = "stop" + self.version += 1 + return [] + + if command == "play": + if self.queue: + self.current = int(args[1]) if len(args) > 1 else 0 + self.state = "play" + return [] + + if command == "pause": + self.state = "pause" if args[1] == "1" else "play" + return [] + + if command == "playlistclear": + self.playlists[args[1]] = [] + return [] + + if command == "playlistadd": + if args[2] == "FAIL": + raise ValueError("No such song") + self.playlists.setdefault(args[1], []).append(args[2]) + return [] + + if command == "rename": + source, destination = args[1], args[2] + if source not in self.playlists: + raise ValueError("No such playlist") + if destination in self.playlists: + raise ValueError("Playlist already exists") + self.playlists[destination] = self.playlists.pop(source) + return [] + + if command == "rm": + if args[1] not in self.playlists: + raise ValueError("No such playlist") + del self.playlists[args[1]] + return [] + + if command in ("update", "rescan"): + self.updating = True + return ["updating_db: 1"] + + if command == "idle": + self.updating = False + return ["changed: update"] + + raise ValueError(f"unsupported command: {raw}") + + +class MPDHandler(socketserver.StreamRequestHandler): + def handle(self): + self.wfile.write(b"OK MPD 0.23.0\n") + self.wfile.flush() + command_list = None + responses = [] + while True: + raw = self.rfile.readline() + if not raw: + return + line = raw.decode().rstrip("\r\n") + if line in ("command_list_begin", "command_list_ok_begin"): + command_list = line + responses = [] + with self.server.state.lock: + self.server.state.commands.append((line, [])) + continue + if line == "command_list_end": + with self.server.state.lock: + self.server.state.commands.append((line, [])) + for response in responses: + for item in response: + self.wfile.write(f"{item}\n".encode()) + if command_list == "command_list_ok_begin": + self.wfile.write(b"list_OK\n") + self.wfile.write(b"OK\n") + self.wfile.flush() + command_list = None + responses = [] + continue + try: + with self.server.state.lock: + response = self.server.state.response(line) + except (ValueError, IndexError) as error: + self.wfile.write(f"ACK [50@0] {{{line.split()[0]}}} {error}\n".encode()) + self.wfile.flush() + command_list = None + responses = [] + continue + if command_list is not None: + responses.append(response) + else: + for item in response: + self.wfile.write(f"{item}\n".encode()) + self.wfile.write(b"OK\n") + self.wfile.flush() + + +class MPDServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self): + self.state = MPDState() + super().__init__(("127.0.0.1", 0), MPDHandler) + + def handle_error(self, request, client_address): + if isinstance(sys.exception(), BrokenPipeError): + return + super().handle_error(request, client_address) + + +class MPDToolsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if BIN_OVERRIDE is None: + subprocess.run( + ["knit", *(f"bin/{tool}" for tool in TOOLS)], + cwd=ROOT, + check=True, + ) + + def setUp(self): + self.server = MPDServer() + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + + def tool_environment(self, extra_env=None): + env = os.environ.copy() + env.update( + MPD_HOST="127.0.0.1", + MPD_PORT=str(self.server.server_address[1]), + MPD_TIMEOUT="2", + MPD_CONFIG_HELPER="off", + ) + if extra_env: + for key, value in extra_env.items(): + if value is None: + env.pop(key, None) + else: + env[key] = value + return env + + def run_tool(self, tool, *args, input_text=None, extra_env=None): + return subprocess.run( + [str(BIN / tool), *args], + input=input_text, + text=True, + capture_output=True, + env=self.tool_environment(extra_env), + timeout=10, + ) + + def seed_queue(self, uris, current=None, state="stop"): + for uri in uris: + self.server.state.add_song(uri) + self.server.state.current = current + self.server.state.state = state + + def test_add_batches_and_preserves_line_content(self): + tracks = [f"https://example.test/{index}" for index in range(1100)] + tracks[5] = " leading and trailing " + text = "# ignored\r\n" + "\r\n".join(tracks) + "\r\n" + result = self.run_tool("mpd_add_to_queue", input_text=text) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Added 1100 tracks", result.stdout) + self.assertEqual([song["uri"] for song in self.server.state.queue], tracks) + begins = [c for c, _ in self.server.state.commands if c == "command_list_begin"] + self.assertEqual(len(begins), 3) + + def test_add_failure_is_nonzero(self): + result = self.run_tool("mpd_add_to_queue", input_text="ok\nFAIL\nlater\n") + self.assertEqual(result.returncode, 1) + self.assertIn("failed input batch", result.stderr) + + def test_replace_uses_ranges_and_preserves_paused_current(self): + old = [f"old/{index}" for index in range(100)] + self.seed_queue(old, current=50, state="pause") + wanted = ["new/a", "new/b", old[50], "new/c", "new/d"] + result = self.run_tool("mpd_update_queue", input_text="\n".join(wanted)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual([song["uri"] for song in self.server.state.queue], wanted) + self.assertEqual(self.server.state.current, 2) + self.assertEqual(self.server.state.state, "pause") + deletes = [ + args for command, args in self.server.state.commands if command == "delete" + ] + self.assertEqual(len(deletes), 2) + self.assertTrue(all(":" in args[0] for args in deletes)) + + def test_failed_queue_replacement_rolls_back(self): + self.seed_queue(["old/a", "old/b"], current=1, state="pause") + result = self.run_tool("mpd_update_queue", input_text="new/a\nFAIL\n") + self.assertEqual(result.returncode, 1) + self.assertIn("restoring the previous queue", result.stderr) + self.assertNotIn("rollback failed", result.stderr) + self.assertEqual( + [song["uri"] for song in self.server.state.queue], + ["old/a", "old/b"], + ) + self.assertEqual(self.server.state.current, 1) + self.assertEqual(self.server.state.state, "pause") + + def test_player_snapshot_retries_if_song_advances(self): + self.seed_queue(["old/a", "old/b", "old/c"], current=0, state="play") + self.server.state.advance_on_current_song = True + result = self.run_tool("mpd_update_queue", input_text="new/a\nold/b\nnew/b\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + [song["uri"] for song in self.server.state.queue], + ["new/a", "old/b", "new/b"], + ) + self.assertEqual(self.server.state.current, 1) + + def test_insert_next_and_trim(self): + self.seed_queue(["a", "b", "c", "d", "e"], current=1, state="play") + result = self.run_tool("mpd_insert_next", input_text="x\ny\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + [song["uri"] for song in self.server.state.queue], + ["a", "b", "x", "y", "c", "d", "e"], + ) + self.server.state.current = 5 + result = self.run_tool("mpd_trim_queue", "--keep", "1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + [song["uri"] for song in self.server.state.queue], ["c", "d", "e"] + ) + self.assertEqual(self.server.state.current, 1) + + def test_playlist_add_and_edit(self): + result = self.run_tool( + "mpd_add_to_playlist", "favorites", input_text="one\ntwo\n" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.state.playlists["favorites"], ["one", "two"]) + + with tempfile.TemporaryDirectory() as directory: + editor = Path(directory) / "editor.sh" + editor.write_text("#!/bin/sh\nprintf 'three\\nfour\\n' > \"$1\"\n") + editor.chmod(0o755) + result = self.run_tool( + "mpd_edit_playlist", + "favorites", + extra_env={"VISUAL": str(editor)}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.state.playlists["favorites"], ["three", "four"]) + + def test_failed_playlist_staging_preserves_original(self): + self.server.state.playlists["favorites"] = ["one", "two"] + with tempfile.TemporaryDirectory() as directory: + editor = Path(directory) / "editor.sh" + editor.write_text("#!/bin/sh\nprintf 'new\\nFAIL\\n' > \"$1\"\n") + editor.chmod(0o755) + result = self.run_tool( + "mpd_edit_playlist", + "favorites", + extra_env={"VISUAL": str(editor)}, + ) + self.assertEqual(result.returncode, 1) + self.assertEqual(self.server.state.playlists["favorites"], ["one", "two"]) + self.assertEqual( + [ + name + for name in self.server.state.playlists + if name.startswith("mpd-tools-") + ], + [], + ) + + def test_queue_editor_preserves_current_and_aborts_on_editor_failure(self): + self.seed_queue(["one", "two"], current=0, state="play") + before = list(self.server.state.queue) + result = self.run_tool("mpd_edit_queue", extra_env={"VISUAL": "/bin/false"}) + self.assertEqual(result.returncode, 1) + self.assertEqual(self.server.state.queue, before) + + with tempfile.TemporaryDirectory() as directory: + editor = Path(directory) / "editor.sh" + editor.write_text("#!/bin/sh\nprintf 'two\\none\\n' > \"$1\"\n") + editor.chmod(0o755) + result = self.run_tool("mpd_edit_queue", extra_env={"VISUAL": str(editor)}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + [song["uri"] for song in self.server.state.queue], ["two", "one"] + ) + self.assertEqual(self.server.state.current, 1) + self.assertEqual(self.server.state.state, "play") + + def test_now_playing_json(self): + self.seed_queue(["track.ogg"], current=0, state="play") + result = self.run_tool("mpd_now_playing", "--json") + self.assertEqual(result.returncode, 0, result.stderr) + value = json.loads(result.stdout) + self.assertEqual(value["state"], "playing") + self.assertEqual(value["artist"], 'Artist "quoted"') + self.assertEqual(value["uri"], "track.ogg") + self.assertEqual(value["elapsed_seconds"], 12.5) + self.assertEqual(value["song_id"], 1) + + def test_explicit_config_helper_overrides_connection_environment(self): + self.seed_queue(["helper.ogg"], current=0, state="play") + with tempfile.TemporaryDirectory() as directory: + helper = Path(directory) / "helper" + helper.write_text( + "#!/bin/sh\n" + "printf 'MPD_HOST=127.0.0.1\\n'\n" + f"printf 'MPD_PORT={self.server.server_address[1]}\\n'\n" + ) + helper.chmod(0o755) + result = self.run_tool( + "mpd_now_playing", + "--json", + extra_env={ + "MPD_CONFIG_HELPER": str(helper), + "MPD_HOST": "invalid.example", + "MPD_PORT": "1", + }, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["uri"], "helper.ogg") + + def test_amen_is_auto_discovered_and_runs_once_across_reconnects(self): + self.seed_queue(["one"], current=0, state="pause") + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + (home / "bin").mkdir() + count = home / "calls" + helper = home / "bin" / "amen" + helper.write_text( + "#!/bin/sh\n" + f"printf x >> {shlex.quote(str(count))}\n" + "printf 'MPD_HOST=127.0.0.1\\n'\n" + f"printf 'MPD_PORT={self.server.server_address[1]}\\n'\n" + ) + helper.chmod(0o755) + result = self.run_tool( + "mpd_edit_queue", + extra_env={ + "HOME": str(home), + "MPD_CONFIG_HELPER": None, + "MPD_HOST": "invalid.example", + "MPD_PORT": "1", + "VISUAL": "/bin/true", + }, + ) + calls = count.read_text() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, "x") + + def test_invalid_config_helper_output_fails_without_fallback(self): + with tempfile.TemporaryDirectory() as directory: + helper = Path(directory) / "invalid-helper" + helper.write_text("#!/bin/sh\nprintf 'MPD_HOST=127.0.0.1\\n'\n") + helper.chmod(0o755) + result = self.run_tool( + "mpd_now_playing", + extra_env={"MPD_CONFIG_HELPER": str(helper)}, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("must print exactly valid MPD_HOST", result.stderr) + + def test_config_helper_timeout_is_bounded(self): + with tempfile.TemporaryDirectory() as directory: + helper = Path(directory) / "slow-helper" + helper.write_text("#!/bin/sh\nexec sleep 30\n") + helper.chmod(0o755) + started = time.monotonic() + result = self.run_tool( + "mpd_now_playing", + extra_env={ + "MPD_CONFIG_HELPER": str(helper), + "MPD_CONFIG_HELPER_TIMEOUT": "1", + }, + ) + elapsed = time.monotonic() - started + self.assertEqual(result.returncode, 1) + self.assertIn("timed out after 1 seconds", result.stderr) + self.assertLess(elapsed, 3) + + def test_editor_uses_tmpdir(self): + self.server.state.playlists["favorites"] = ["one"] + with tempfile.TemporaryDirectory() as directory: + editor = Path(directory) / "editor.sh" + editor.write_text( + "#!/bin/sh\n" + f'case "$1" in {shlex.quote(directory)}/*) exit 0;; *) exit 1;; esac\n' + ) + editor.chmod(0o755) + result = self.run_tool( + "mpd_edit_playlist", + "favorites", + extra_env={"VISUAL": str(editor), "TMPDIR": directory}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_watch_returns_signal_specific_status(self): + process = subprocess.Popen( + [str(BIN / "mpd_now_playing"), "--watch"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + env=self.tool_environment(), + ) + try: + time.sleep(0.1) + process.terminate() + _, stderr = process.communicate(timeout=3) + finally: + if process.poll() is None: + process.kill() + process.wait() + self.assertEqual(process.returncode, 128 + 15, stderr) + + def test_empty_replacement_clears_queue(self): + self.seed_queue(["one", "two"], current=0, state="pause") + result = self.run_tool("mpd_update_queue", input_text="") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.state.queue, []) + self.assertEqual(self.server.state.state, "stop") + + def test_library_update_deduplicates_without_fixed_delay(self): + result = self.run_tool( + "mpd_update_library", + "--no-wait", + input_text="rock/a.ogg\nrock/b.ogg\njazz/c.ogg\n", + ) + self.assertEqual(result.returncode, 0, result.stderr) + updates = [ + args for command, args in self.server.state.commands if command == "update" + ] + self.assertEqual(updates, [["jazz"], ["rock"]]) + + def test_library_update_waits_on_idle_event(self): + result = self.run_tool("mpd_update_library", input_text="rock/a.ogg\n") + self.assertEqual(result.returncode, 0, result.stderr) + commands = [command for command, _ in self.server.state.commands] + self.assertIn("idle", commands) + + def test_help_and_invalid_usage(self): + for tool in TOOLS: + result = self.run_tool(tool, "--help") + self.assertEqual(result.returncode, 0, f"{tool}: {result.stderr}") + self.assertIn("Usage:", result.stdout) + result = self.run_tool("mpd_trim_queue", "--keep", "nope") + self.assertEqual(result.returncode, 2) + result = self.run_tool("mpd_add_to_queue", "--unknown") + self.assertEqual(result.returncode, 2) + + +if __name__ == "__main__": + unittest.main()