commit 19119d2fedbe539eb780c3d76bbcd9a8f66703f0 from: mtmn date: Tue Aug 11 10:18:10 2026 UTC add mpd_report_daily script 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() +