commit - a973348dc123448b654e5e3786a8d2d2ad9c91bf
commit + a1ff38e7714d72b4bbc719d1f9c575fc18a474ed
blob - 4bec2cccc2a20e7291cb0ec0409101f37c6d65c5
blob + 7de2e10c90c62f793607f601508389281f1e6880
--- mpd/README.md
+++ mpd/README.md
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
struct options {
const char *host;
const char *path;
+ const char *output;
const char *tracks_output;
unsigned port;
bool include_files;
" -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"
}
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[] = {
{"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'},
};
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':
case 'p':
options->path = optarg;
break;
+ case 'o':
+ options->output = optarg;
+ break;
case 'H':
options->host = optarg;
break;
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;
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;
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;
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;
}
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;
}
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;
}
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);
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
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: "."}
["--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
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",
[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(
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(
"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
import unittest
from pathlib import Path
-
HELPER = Path(__file__).resolve().parents[1] / "mpd_report_monthly"
arguments_file.read_text().splitlines(),
[
"--files",
+ "--output",
+ "-",
"--window",
"1782864000,1783468800",
"--window",