commit a1ff38e7714d72b4bbc719d1f9c575fc18a474ed
parent a973348dc123448b654e5e3786a8d2d2ad9c91bf
Author: mtmn <miro@haravara.org>
Date: Sat, 8 Aug 2026 22:23:11 +0200
mpd_report output to a file unless stdout is defined
Diffstat:
5 files changed, 83 insertions(+), 24 deletions(-)
diff --git a/mpd/README.md b/mpd/README.md
@@ -28,7 +28,9 @@ before moving the old playlist aside. `--force` bypasses concurrency checks.
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.
+Results atomically replace `all.txt` by default; `--output FILE` chooses another
+path and `--output -` explicitly writes them to stdout. `--tracks-output FILE`
+atomically writes all song URIs during the same scan.
`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
diff --git a/mpd/mpd_report.c b/mpd/mpd_report.c
@@ -25,6 +25,7 @@ struct window {
struct options {
const char *host;
const char *path;
+ const char *output;
const char *tracks_output;
unsigned port;
bool include_files;
@@ -51,6 +52,8 @@ static void usage(FILE *stream, const char *program) {
" -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"
+ " -o, --output FILE atomically write results to FILE (default: "
+ "all.txt); '-' uses stdout\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"
@@ -133,7 +136,7 @@ static bool append_window(struct options *options, const char *argument) {
}
static bool parse_options(int argc, char **argv, struct options *options) {
- *options = (struct options){.path = ""};
+ *options = (struct options){.path = "", .output = "all.txt"};
enum { OPT_TRACKS_OUTPUT = 256 };
static const struct option long_options[] = {
@@ -142,6 +145,7 @@ static bool parse_options(int argc, char **argv, struct options *options) {
{"indexed", no_argument, NULL, 'i'},
{"relative", no_argument, NULL, 'r'},
{"path", required_argument, NULL, 'p'},
+ {"output", required_argument, NULL, 'o'},
{"tracks-output", required_argument, NULL, OPT_TRACKS_OUTPUT},
{"host", required_argument, NULL, 'H'},
{"port", required_argument, NULL, 'P'},
@@ -150,7 +154,7 @@ static bool parse_options(int argc, char **argv, struct options *options) {
};
int option = 0;
- while ((option = getopt_long(argc, argv, "w:firp:H:P:h", long_options,
+ while ((option = getopt_long(argc, argv, "w:firp:o:H:P:h", long_options,
NULL)) != -1) {
switch (option) {
case 'w':
@@ -169,6 +173,9 @@ static bool parse_options(int argc, char **argv, struct options *options) {
case 'p':
options->path = optarg;
break;
+ case 'o':
+ options->output = optarg;
+ break;
case 'H':
options->host = optarg;
break;
@@ -195,6 +202,15 @@ static bool parse_options(int argc, char **argv, struct options *options) {
fprintf(stderr, "unexpected argument '%s'\n", argv[optind]);
return false;
}
+ if (options->output[0] == '\0') {
+ fprintf(stderr, "output path must not be empty\n");
+ return false;
+ }
+ if (options->tracks_output != NULL &&
+ strcmp(options->tracks_output, options->output) == 0) {
+ fprintf(stderr, "--output and --tracks-output must name different files\n");
+ return false;
+ }
if (options->window_count == 0U && !append_window(options, "-,-"))
return false;
return true;
@@ -216,7 +232,7 @@ static bool atomic_file_open(struct atomic_file *file, const char *path) {
int descriptor = mkstemp(file->temporary_path);
if (descriptor < 0) {
- fprintf(stderr, "opening temporary track list for '%s': %s\n", path,
+ fprintf(stderr, "opening temporary output for '%s': %s\n", path,
strerror(errno));
free(file->temporary_path);
file->temporary_path = NULL;
@@ -228,7 +244,7 @@ static bool atomic_file_open(struct atomic_file *file, const char *path) {
close(descriptor);
unlink(file->temporary_path);
errno = error;
- perror("opening temporary track list stream");
+ perror("opening temporary output stream");
free(file->temporary_path);
file->temporary_path = NULL;
return false;
@@ -256,13 +272,13 @@ static bool atomic_file_commit(struct atomic_file *file) {
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,
+ fprintf(stderr, "writing output file '%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,
+ fprintf(stderr, "installing output file '%s': %s\n", file->final_path,
strerror(errno));
success = false;
}
@@ -300,17 +316,17 @@ static const char *relative_uri(const char *uri,
return uri;
}
-static bool emit_matches(const struct options *options, const char *uri,
- time_t modified) {
+static bool emit_matches(const struct options *options, FILE *stream,
+ 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);
+ written = fprintf(stream, "%zu\t%s\n", index, output_uri);
else
- written = printf("%s\n", output_uri);
+ written = fprintf(stream, "%s\n", output_uri);
if (written < 0)
return false;
}
@@ -322,9 +338,22 @@ static bool query(const struct options *options) {
if (connection == NULL)
return false;
+ struct atomic_file results = {0};
+ FILE *result_stream = stdout;
+ bool results_to_stdout = strcmp(options->output, "-") == 0;
+ if (!results_to_stdout) {
+ if (!atomic_file_open(&results, options->output)) {
+ mpd_connection_free(connection);
+ return false;
+ }
+ result_stream = results.stream;
+ }
+
struct atomic_file tracks = {0};
if (options->tracks_output != NULL &&
!atomic_file_open(&tracks, options->tracks_output)) {
+ if (results.stream != NULL)
+ atomic_file_abort(&results);
mpd_connection_free(connection);
return false;
}
@@ -358,8 +387,10 @@ static bool query(const struct options *options) {
uri = NULL;
}
- if (success && uri != NULL && !emit_matches(options, uri, modified)) {
- perror("writing results");
+ if (success && uri != NULL &&
+ !emit_matches(options, result_stream, uri, modified)) {
+ fprintf(stderr, "writing results to '%s': %s\n", options->output,
+ strerror(errno));
success = false;
}
mpd_entity_free(entity);
@@ -370,9 +401,16 @@ static bool query(const struct options *options) {
mpd_connection_get_error_message(connection));
success = false;
}
- if (success && fflush(stdout) != 0) {
- perror("writing results");
- success = false;
+ if (results_to_stdout) {
+ if (success && fflush(stdout) != 0) {
+ perror("writing results");
+ success = false;
+ }
+ } else if (results.stream != NULL) {
+ if (success)
+ success = atomic_file_commit(&results);
+ else
+ atomic_file_abort(&results);
}
if (tracks.stream != NULL) {
diff --git a/mpd/mpd_report_monthly b/mpd/mpd_report_monthly
@@ -7,7 +7,7 @@ require "optparse"
require "tempfile"
REPORT_COUNT = 4
-MANAGED_MPD_OPTIONS = %w[-i --indexed -w --window].freeze
+MANAGED_MPD_OPTIONS = %w[-i --indexed -o --output -w --window].freeze
def main(arguments)
options = {output_directory: "."}
@@ -51,7 +51,7 @@ def main(arguments)
["--window", epochs.join(",")]
end
- stdout, stderr, status = Open3.capture3(command, *mpd_options, *windows)
+ stdout, stderr, status = Open3.capture3(command, *mpd_options, "--output", "-", *windows)
warn stderr unless stderr.empty?
return status.exitstatus || 1 unless status.success?
diff --git a/mpd/tests/test_mpd_report.py b/mpd/tests/test_mpd_report.py
@@ -97,7 +97,7 @@ class MpdReportTest(unittest.TestCase):
self.server.server_close()
self.thread.join(timeout=2)
- def run_tool(self, *arguments):
+ def run_tool(self, *arguments, cwd=None):
environment = os.environ.copy()
environment.update(
MPD_HOST="127.0.0.1",
@@ -108,24 +108,31 @@ class MpdReportTest(unittest.TestCase):
[str(self.binary), *arguments],
text=True,
capture_output=True,
+ cwd=cwd,
env=environment,
timeout=5,
)
def test_all_time_writes_plain_directories_and_tracks(self):
with tempfile.TemporaryDirectory() as directory:
+ report = Path(directory) / "report.txt"
tracks = Path(directory) / "all_tracks.txt"
- result = self.run_tool("--tracks-output", str(tracks))
+ result = self.run_tool(
+ "--output", str(report), "--tracks-output", str(tracks)
+ )
self.assertEqual(result.returncode, 0, result.stderr)
- self.assertEqual(result.stdout, "Artist\nArtist/Album\n")
+ self.assertEqual(result.stdout, "")
+ self.assertEqual(report.read_text(), "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)
+ result = self.run_tool(
+ "--output", "-", "--files", "--window", first, "--window", second
+ )
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
@@ -136,7 +143,7 @@ class MpdReportTest(unittest.TestCase):
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)
+ result = self.run_tool("--output", "-", "--window", broad, "--window", narrow)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
@@ -144,6 +151,17 @@ class MpdReportTest(unittest.TestCase):
"0\tArtist\n" "0\tArtist/Album\n" "1\tArtist/Album\n",
)
+ def test_default_output_is_all_txt_and_stdout_stays_empty(self):
+ with tempfile.TemporaryDirectory() as directory:
+ result = self.run_tool(cwd=directory)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "")
+ self.assertEqual(
+ (Path(directory) / "all.txt").read_text(),
+ "Artist\nArtist/Album\n",
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/mpd/tests/test_mpd_report_monthly.py b/mpd/tests/test_mpd_report_monthly.py
@@ -4,7 +4,6 @@ import tempfile
import unittest
from pathlib import Path
-
HELPER = Path(__file__).resolve().parents[1] / "mpd_report_monthly"
@@ -66,6 +65,8 @@ class MpdReportMonthlyTest(unittest.TestCase):
arguments_file.read_text().splitlines(),
[
"--files",
+ "--output",
+ "-",
"--window",
"1782864000,1783468800",
"--window",