Commit Diff


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 `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
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",