commit - 31e05974545e3f509c95cb9df3729822f9d3e3d6
commit + 173ea72d580c5c3ddf834d0dae1b5e460e7aca83
blob - 581075845b1c4c214c56fed9f07a5d7fbb525804
blob + 0237e323f7eab7456591a8e0022e4f9c9e5f533b
--- .gitignore
+++ .gitignore
bin/*
# python
.venv/
+__pycache__/
+*.py[cod]
# zig
.zig-cache
zig-out
blob - 288cf09f95427e4f4072efcc28759f99eee863f6
blob + 0896a45076225f2350f9b0f4c72ff37cc072dc77
--- README.md
+++ README.md
just deploy <target>
```
-`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.
## 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
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:
{{ 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
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
$ 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
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
# 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 `INDEX<TAB>URI`, 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
-#include <mpd/client.h>
-#include <stdio.h>
-#include <stdlib.h>
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
#include <string.h>
-// #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 <unistd.h>
- // 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
-#include <mpd/client.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <unistd.h>
-
-// #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 <string.h>
+#include <unistd.h>
+
+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
-#include <mpd/client.h>
-#include <stdio.h>
-#include <stdlib.h>
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <limits.h>
#include <string.h>
-#include <stdbool.h>
-#include <unistd.h>
-#include <sys/wait.h>
-#include <ctype.h>
-#include <fcntl.h>
-#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
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <poll.h>
+#include <signal.h>
+#include <spawn.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <unistd.h>
+#include <wordexp.h>
+
+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
-#include <ctype.h>
-#include <mpd/client.h>
-#include <stdbool.h>
-#include <stdio.h>
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <errno.h>
#include <stdlib.h>
#include <string.h>
+#include <time.h>
#include <unistd.h>
-#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
+#ifndef MPD_COMMON_H
+#define MPD_COMMON_H
+
+#include <mpd/client.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+
+#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
-#include <mpd/client.h>
-#include <stdio.h>
-#include <stdlib.h>
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <limits.h>
#include <string.h>
-#include <stdbool.h>
#include <unistd.h>
-#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 <new_queue_file>\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
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <string.h>
+
+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
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <limits.h>
+#include <string.h>
+#include <unistd.h>
+
+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
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <errno.h>
+#include <signal.h>
+#include <string.h>
+#include <time.h>
+
+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
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <errno.h>
+#include <getopt.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <mpd/client.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+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_INDEX<TAB>URI. 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
+#!/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
+#define _POSIX_C_SOURCE 200809L
+
+#include "mpd_common.h"
+
+#include <limits.h>
+#include <string.h>
+
+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
+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
+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
+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()