Commit Diff


commit - c015dc5373a588d0d9670a44686bd14afebac022
commit + 19119d2fedbe539eb780c3d76bbcd9a8f66703f0
blob - 66d8eab07b59e565113a3e88d207f891f343d06a
blob + d0252a3e90028a99fc73e68f95ce17067c731e31
--- knitfile
+++ knitfile
@@ -10,7 +10,7 @@ python_bins := bin/diggah bin/lazymaster bin/nts bin/s
 ruby_bins := bin/hue bin/reflink-snap
 mpd_queue_bins := bin/mpd_add_to_queue bin/mpd_edit_queue bin/mpd_update_queue bin/mpd_insert_next bin/mpd_trim_queue
 mpd_playlist_bins := bin/mpd_add_to_playlist bin/mpd_edit_playlist bin/mpd_update_library
-mpd_report_bins := bin/mpd_now_playing bin/mpd_report bin/mpd_report_monthly
+mpd_report_bins := bin/mpd_now_playing bin/mpd_report bin/mpd_report_monthly bin/mpd_report_daily
 mpd_bins := $mpd_queue_bins $mpd_playlist_bins $mpd_report_bins
 bins := $rust_bins $zig_bins $go_bins $mpd_bins $python_bins $ruby_bins
 
@@ -131,4 +131,8 @@ $ deploy/mpd:VB: deploy-dir $mpd_bins
 $ deploy/%:VB: deploy-dir bin/%
     install -m 755 bin/$match $deploy_dir/
     echo "deployed $match to $deploy_dir"
+
+$ bin/mpd_report_daily: mpd/mpd_report_daily
+    cp mpd/mpd_report_daily $output
+    chmod +x $output
 }
blob - 7de2e10c90c62f793607f601508389281f1e6880
blob + f5ed6a49ffe3ace1b6b34ba9dd8688bda078c8df
--- mpd/README.md
+++ mpd/README.md
@@ -19,6 +19,7 @@ starting with `#` are ignored. Use `-` for stdin.
 | `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. |
+| `mpd_report_daily` | `MONTH [YEAR] [MPD_REPORT_OPTION ...]` | Write daily reports for each day of a month. |
 
 Queue editing detects concurrent changes and attempts to restore the previous
 queue after a replacement failure. Playlist edits stage non-empty replacements
@@ -39,6 +40,12 @@ other `mpd_report` options after `--`. It uses the cur
 accepts `-o DIRECTORY`, and replaces reports atomically after a successful MPD
 query.
 
+`mpd_report_daily` is a Ruby helper for daily reports. Run `mpd_report_daily 07 2026` to
+write `01_07_2026.txt` through `31_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.
+
 ## Configuration
 
 | Variable | Default | Purpose |
@@ -60,6 +67,6 @@ arguments.
 ## Development
 
 ```sh
-just build mpd_report mpd_report_monthly
+just build mpd_report mpd_report_monthly mpd_report_daily
 python3 -m unittest discover -s mpd/tests -v
 ```
blob - /dev/null
blob + 19f8834f353e462655782920915fb7819a2eea80 (mode 755)
--- /dev/null
+++ mpd/mpd_report_daily
@@ -0,0 +1,95 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "date"
+require "open3"
+require "optparse"
+require "tempfile"
+
+MANAGED_MPD_OPTIONS = %w[-i --indexed -o --output -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)
+  last = first.next_month - 1
+  report_count = last.day
+  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 |day|
+    dates = [first + day, first + day + 1]
+    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, "--output", "-", *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\d+\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|
+    date = first + index
+    name = format("%02d_%02d_%04d.txt", date.day, date.month, date.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 + 2bc960bff7d37193a0722371ba579910112d0bd1 (mode 644)
--- /dev/null
+++ mpd/tests/test_mpd_report_daily.py
@@ -0,0 +1,146 @@
+import os
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+HELPER = Path(__file__).resolve().parents[1] / "mpd_report_daily"
+
+
+class MpdReportDailyTest(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\\tDay1\\n1\\tDay2\\n30\\tDay31\\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),
+                    "07",
+                    "2026",
+                    "--",
+                    "--files",
+                ],
+                text=True,
+                capture_output=True,
+                env=environment,
+                timeout=5,
+            )
+
+            self.assertEqual(result.returncode, 0, result.stderr)
+            self.assertEqual((directory / "01_07_2026.txt").read_text(), "Day1\n")
+            self.assertEqual((directory / "02_07_2026.txt").read_text(), "Day2\n")
+            self.assertEqual((directory / "03_07_2026.txt").read_text(), "")
+            self.assertEqual((directory / "31_07_2026.txt").read_text(), "Day31\n")
+
+            arguments = arguments_file.read_text().splitlines()
+            self.assertEqual(arguments[:3], ["--files", "--output", "-"])
+            windows = arguments[3:]
+            self.assertEqual(len(windows), 31 * 2)
+            self.assertEqual(windows[0], "--window")
+            self.assertEqual(windows[1], "1751241600,1751328000")
+            self.assertEqual(windows[-2], "--window")
+            self.assertEqual(windows[-1], "1753910400,1753996800")
+
+    def test_failed_query_does_not_replace_existing_reports(self):
+        with tempfile.TemporaryDirectory() as name:
+            directory = Path(name)
+            report = directory / "01_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 / "02_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)
+
+    def test_february_non_leap_year(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 '27\\tLastDay\\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),
+                    "02",
+                    "2025",
+                    "--",
+                ],
+                text=True,
+                capture_output=True,
+                env=environment,
+                timeout=5,
+            )
+
+            self.assertEqual(result.returncode, 0, result.stderr)
+            self.assertEqual((directory / "28_02_2025.txt").read_text(), "LastDay\n")
+            arguments = arguments_file.read_text().splitlines()
+            windows = arguments[3:]
+            self.assertEqual(len(windows), 28 * 2)
+
+
+    def test_uses_current_year_when_year_omitted(self):
+        from datetime import date
+        current_year = date.today().year
+        with tempfile.TemporaryDirectory() as name:
+            directory = Path(name)
+            fake = self.make_fake(directory, "printf '0\tToday\n'\n")
+            result = self.run_helper(directory, fake, "01")
+            self.assertEqual(result.returncode, 0, result.stderr)
+            expected = directory / f"01_01_{current_year}.txt"
+            self.assertTrue(expected.exists())
+            self.assertEqual(expected.read_text(), "Today\n")
+
+if __name__ == "__main__":
+    unittest.main()
+