commit a1ff38e7714d72b4bbc719d1f9c575fc18a474ed from: mtmn date: Sat Aug 8 20:23:11 2026 UTC mpd_report output to a file unless stdout is defined commit - a973348dc123448b654e5e3786a8d2d2ad9c91bf commit + a1ff38e7714d72b4bbc719d1f9c575fc18a474ed blob - 4bec2cccc2a20e7291cb0ec0409101f37c6d65c5 blob + 7de2e10c90c62f793607f601508389281f1e6880 --- mpd/README.md +++ mpd/README.md @@ -28,7 +28,9 @@ before moving the old playlist aside. `--force` bypass means unbounded. Directories are emitted by default; `--files` includes songs, `--path URI` limits the search, and `--relative` strips that prefix. Multiple windows produce `INDEXURI`, including every matching overlapping window. -`--tracks-output FILE` atomically writes all song URIs during the same scan. +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 blob - 21ea049c9fd2c480673e6ec649db75d6efa1aab3 blob + e7b9aabd768b155bb04011057ac293e4bc39954b --- mpd/mpd_report.c +++ 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, con } 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, struc {"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, struc }; 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, struc 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, struc 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, 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, 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 *fil 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) { blob - c0f67d5514b70bf566ae9d403c65451e1d4e6ec7 blob + a9903b4ee812b8571c8bcab78ae7b241668f8c7d --- mpd/mpd_report_monthly +++ 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? blob - 227a6851c9c4f9a6419b0ccc3bb7e821882d1799 blob + b886acebadea86ef43cc2cb9053ba274921781ff --- mpd/tests/test_mpd_report.py +++ 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() blob - a3faac462834a8fb55c3f613628d24e441484578 blob + fe0701bb93972102387db6127125af52bc5011df --- mpd/tests/test_mpd_report_monthly.py +++ 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",