tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

commit c7247ea20010644f0283207537b886a9a9898028
Author: mtmn <miro@haravara.org>
Date:   Fri, 23 Jan 2026 11:45:10 +0100

init

Diffstat:
A.bazelrc | 26++++++++++++++++++++++++++
A.bazelversion | 1+
A.gitignore | 17+++++++++++++++++
A.pre-commit-config.yaml | 5+++++
ABUILD | 23+++++++++++++++++++++++
ACargo.toml | 17+++++++++++++++++
AMODULE.bazel | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aaaltomuoto/BUILD | 21+++++++++++++++++++++
Aaaltomuoto/README.md | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aaaltomuoto/__pycache__/aaltomuoto.cpython-314.pyc | 0
Aaaltomuoto/aaltomuoto.py | 723+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aalafont/BUILD | 7+++++++
Aalafont/Cargo.toml | 8++++++++
Aalafont/src/main.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Abandeno/.dockerignore | 1+
Abandeno/Containerfile | 19+++++++++++++++++++
Abandeno/deno.json | 10++++++++++
Abandeno/deno.lock | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abandeno/deploy | 2++
Abandeno/main.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abandeno/todo.txt | 1+
Abazel/BUILD | 1+
Abazel/local-deploy.bzl | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abazel/rust.bzl | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adiffamer/BUILD | 7+++++++
Adiffamer/Cargo.toml | 12++++++++++++
Adiffamer/src/main.rs | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adiggah/BUILD | 19+++++++++++++++++++
Adiggah/README.md | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adiggah/diggah.py | 410+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adiggah/pyrightconfig.json | 5+++++
Af2aif/BUILD | 15+++++++++++++++
Af2aif/f2aif.py | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahakunadata/BUILD | 7+++++++
Ahakunadata/Cargo.toml | 16++++++++++++++++
Ahakunadata/README.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ahakunadata/src/context.rs | 7+++++++
Ahakunadata/src/fetchers/discogs.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahakunadata/src/fetchers/mod.rs | 2++
Ahakunadata/src/fetchers/musicbrainz.rs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahakunadata/src/main.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahakunadata/src/metadata/fetch.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahakunadata/src/metadata/genres.rs | 16++++++++++++++++
Ahakunadata/src/metadata/labels.rs | 16++++++++++++++++
Ahakunadata/src/metadata/mod.rs | 4++++
Ahakunadata/src/metadata/subgenres.rs | 16++++++++++++++++
Ahakunadata/src/tagging.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/.bundle/config | 2++
Ahue/.gitignore | 1+
Ahue/Gemfile | 5+++++
Ahue/Gemfile.lock | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/README.md | 318+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/Rakefile | 45+++++++++++++++++++++++++++++++++++++++++++++
Ahue/bin/hue | 7+++++++
Ahue/hue-0.1.0.gem | 0
Ahue/hue.gemspec | 29+++++++++++++++++++++++++++++
Ahue/lib/config/aliases.yml | 12++++++++++++
Ahue/lib/hue.rb | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/animations/candle.rb | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/animations/sunrise.rb | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/bridge.rb | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/bulb.rb | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/cli.rb | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/cli/command.rb | 44++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/cli/commands/delete.rb | 25+++++++++++++++++++++++++
Ahue/lib/hue/cli/commands/light.rb | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/cli/commands/lights.rb | 33+++++++++++++++++++++++++++++++++
Ahue/lib/hue/cli/commands/register.rb | 34++++++++++++++++++++++++++++++++++
Ahue/lib/hue/cli/processors/light_state_alias.rb | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/colors.rb | 43+++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/colors/color.rb | 32++++++++++++++++++++++++++++++++
Ahue/lib/hue/colors/color_temperature.rb | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/colors/hue_saturation.rb | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/colors/rgb.rb | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/colors/xy.rb | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/config/abstract.rb | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/config/application.rb | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/config/bridge.rb | 47+++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/extensions/bridge.rb | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/extensions/bulb.rb | 43+++++++++++++++++++++++++++++++++++++++++++
Ahue/lib/hue/extensions/hue.rb | 7+++++++
Ahue/lib/hue/utilities/indentation.rb | 34++++++++++++++++++++++++++++++++++
Ahue/pkg/hue-0.1.0.gem | 0
Ahue/spec/cli_spec.rb | 15+++++++++++++++
Ahue/spec/config/applications.yml | 4++++
Ahue/spec/config/bridges.yml | 3+++
Ahue/spec/config/empty.yml | 1+
Ahue/spec/hue/bridge_spec.rb | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/bulb_spec.rb | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/colors/color_spec.rb | 33+++++++++++++++++++++++++++++++++
Ahue/spec/hue/colors/color_temperature_spec.rb | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/colors/hue_saturation_spec.rb | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/colors/rgb_spec.rb | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/colors/xy_spec.rb | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/colors_spec.rb | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/config/abstract_spec.rb | 45+++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/config/application_spec.rb | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/hue/config/bridge_spec.rb | 37+++++++++++++++++++++++++++++++++++++
Ahue/spec/hue_spec.rb | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/json/application_uuid/lights/post_success.json | 7+++++++
Ahue/spec/json/config.json | 27+++++++++++++++++++++++++++
Ahue/spec/json/config/whitelist/application_uuid/delete_success.json | 1+
Ahue/spec/json/get_success.json | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahue/spec/json/lights.json | 14++++++++++++++
Ahue/spec/json/lights/1.json | 28++++++++++++++++++++++++++++
Ahue/spec/json/post_error.json | 1+
Ahue/spec/json/post_success.json | 8++++++++
Ahue/spec/json/put_success.json | 1+
Ahue/spec/json/schedules.json | 14++++++++++++++
Ahue/spec/json/unauthorized.json | 10++++++++++
Ahue/spec/spec_helper.rb | 11+++++++++++
Ajustfile | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/.formatter.exs | 4++++
Akhal_notifier/.gitignore | 4++++
Akhal_notifier/AGENTS.md | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/BUILD | 13+++++++++++++
Akhal_notifier/Makefile | 24++++++++++++++++++++++++
Akhal_notifier/README.md | 21+++++++++++++++++++++
Akhal_notifier/build.exs | 30++++++++++++++++++++++++++++++
Akhal_notifier/flake.lock | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/flake.nix | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/lib/BUILD | 5+++++
Akhal_notifier/lib/khal_notifier.ex | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/mix.exs | 41+++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/mix.lock | 18++++++++++++++++++
Akhal_notifier/rewrite_in_bb/example.bb | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akhal_notifier/rewrite_in_bb/khal_notifier.bb | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alastfm-csv-export/BUILD | 7+++++++
Alastfm-csv-export/Cargo.toml | 11+++++++++++
Alastfm-csv-export/src/main.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazymaster/BUILD | 20++++++++++++++++++++
Alazymaster/README.md | 16++++++++++++++++
Alazymaster/lazymaster.py | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/BUILD | 7+++++++
Amack/Cargo.toml | 22++++++++++++++++++++++
Amack/LICENSE | 21+++++++++++++++++++++
Amack/README.md | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/src/config.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/src/main.rs | 345+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/src/prompt.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Amack/src/track/feat.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/src/track/fixers.rs | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/src/track/loader.rs | 16++++++++++++++++
Amack/src/track/mod.rs | 15+++++++++++++++
Amack/src/track/rename.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amack/src/util.rs | 6++++++
Amack/todo.txt | 1+
Amatchakey/BUILD | 33+++++++++++++++++++++++++++++++++
Amatchakey/Cargo.toml | 17+++++++++++++++++
Amatchakey/README.md | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/src/db.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/src/drift.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/src/key.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/src/lib.rs | 5+++++
Amatchakey/src/main.rs | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/src/track.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/src/utils.rs | 12++++++++++++
Amatchakey/tests/integration_tests.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatchakey/tests/utils_tests.rs | 19+++++++++++++++++++
Amisc/hehe.png | 0
Ants/BUILD | 19+++++++++++++++++++
Ants/nts.py | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-client/BUILD | 10++++++++++
Aplants-client/Cargo.toml | 17+++++++++++++++++
Aplants-client/src/main.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-common/BUILD | 13+++++++++++++
Aplants-common/Cargo.toml | 13+++++++++++++
Aplants-common/src/lib.rs | 2++
Aplants-common/src/output.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-common/src/status.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/BUILD | 10++++++++++
Aplants-daemon/Cargo.toml | 25+++++++++++++++++++++++++
Aplants-daemon/src/airpods.rs | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/airpods_consts.rs | 20++++++++++++++++++++
Aplants-daemon/src/bluetooth.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/config.rs | 35+++++++++++++++++++++++++++++++++++
Aplants-daemon/src/daemon_impl.rs | 10++++++++++
Aplants-daemon/src/main.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/packets/battery.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/packets/in_ear.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/packets/metadata.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/packets/mod.rs | 5+++++
Aplants-daemon/src/pbp.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplants-daemon/src/pbp_client.rs | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apmn/.gitignore | 2++
Apmn/BUILD | 19+++++++++++++++++++
Apmn/README.md | 20++++++++++++++++++++
Apmn/build.zig | 16++++++++++++++++
Apmn/build.zig.zon | 13+++++++++++++
Apmn/justfile | 22++++++++++++++++++++++
Apmn/pmn.png | 0
Apmn/src/main.zig | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/.gitignore | 4++++
Apracomer/BUILD | 19+++++++++++++++++++
Apracomer/build.zig | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/build.zig.zon | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/command.zig | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/main.zig | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/options.zig | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/pausable_timer.zig | 43+++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/signals.zig | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/step.zig | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apracomer/src/terminal.zig | 28++++++++++++++++++++++++++++
Arequirements.in | 8++++++++
Arequirements.out | 633+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arustfmt.toml | 2++
Ashirt_linkener/.gitignore | 4++++
Ashirt_linkener/BUILD | 13+++++++++++++
Ashirt_linkener/Containerfile | 20++++++++++++++++++++
Ashirt_linkener/Makefile | 32++++++++++++++++++++++++++++++++
Ashirt_linkener/config/config.exs | 7+++++++
Ashirt_linkener/config/runtime.exs | 10++++++++++
Ashirt_linkener/deploy | 2++
Ashirt_linkener/lib/shortener/application.ex | 43+++++++++++++++++++++++++++++++++++++++++++
Ashirt_linkener/lib/shortener/db.ex | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ashirt_linkener/lib/shortener/router.ex | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ashirt_linkener/mix.exs | 31+++++++++++++++++++++++++++++++
Ashirt_linkener/mix.lock | 27+++++++++++++++++++++++++++
Ashuffle/BUILD | 15+++++++++++++++
Ashuffle/shuffle.py | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspeediness/.gitignore | 2++
Aspeediness/BUILD | 19+++++++++++++++++++
Aspeediness/build.zig | 28++++++++++++++++++++++++++++
Aspeediness/build.zig.zon | 12++++++++++++
Aspeediness/src/main.zig | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avirittaa/BUILD | 7+++++++
Avirittaa/Cargo.toml | 22++++++++++++++++++++++
Avirittaa/Makefile.toml | 35+++++++++++++++++++++++++++++++++++
Avirittaa/README.md | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avirittaa/src/audio_processing.rs | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avirittaa/src/constants.rs | 24++++++++++++++++++++++++
Avirittaa/src/file_io.rs | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avirittaa/src/main.rs | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avirittaa/src/types.rs | 16++++++++++++++++
235 files changed, 15274 insertions(+), 0 deletions(-)

diff --git a/.bazelrc b/.bazelrc @@ -0,0 +1,26 @@ +build --incompatible_enable_cc_toolchain_resolution +build --enable_platform_specific_config + +build --compilation_mode=dbg +build --strip=never + +build:release --compilation_mode=opt +build:release --strip=always +build:release --copt=-O3 + +build:profiling --compilation_mode=opt +build:profiling --strip=never +build:profiling --copt=-g + +build --linkopt=-fuse-ld=lld + +build --copt=-march=native +build --copt=-mtune=native + +build --verbose_failures + +build --jobs=auto + +build --@rules_rust//rust/toolchain/channel=nightly + +build --remote_cache=https://bazel.saatana.cat diff --git a/.bazelversion b/.bazelversion @@ -0,0 +1 @@ +9.0.1 diff --git a/.gitignore b/.gitignore @@ -0,0 +1,17 @@ +# bazel +bazel-* +# zig +.zig-cache +zig-out +# elixir +.expert +_build +# make gen-rust-project +rust-project.toml +# hack/git-diff-upstream.sh +*.upstream +# quickemu +*.qcow2 +*.iso +# rust +rust-project.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.0 + hooks: + - id: gitleaks diff --git a/BUILD b/BUILD @@ -0,0 +1,23 @@ +load("@aspect_rules_lint//format:defs.bzl", "format_multirun") +load("@rules_python//python:pip.bzl", "compile_pip_requirements") +load("@gazelle//:def.bzl", "gazelle") + +# gazelle:prefix njalla_dns +gazelle(name = "gazelle") + +compile_pip_requirements( + name = "requirements", + src = "requirements.in", + requirements_txt = "requirements.out", +) + +format_multirun( + name = "format", + rust = "@rules_rust//:rustfmt", +) + +exports_files([ + "rustfmt.toml", + "clippy.toml", + "Cargo.lock", +]) diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +resolver = "2" +members = [ + "alafont", + "bliss/blissify", + "diffamer", + "hakunadata", + "lastfm-csv-export", + "mack", + "matchakey", + "plants-client", + "plants-common", + "plants-daemon", + "scores", + "virittaa", + "battery-notify" +] diff --git a/MODULE.bazel b/MODULE.bazel @@ -0,0 +1,103 @@ +module( + name = "tools", +) + +bazel_dep(name = "platforms", version = "1.0.0") +bazel_dep(name = "bazel_skylib", version = "1.9.0") +bazel_dep(name = "aspect_rules_lint", version = "2.2.0") +bazel_dep(name = "hermetic_cc_toolchain", version = "4.1.0") +bazel_dep(name = "cgrindel_bazel_starlib", version = "0.30.0") +bazel_dep(name = "rules_tf", version = "0.0.10") +bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "rules_zig", version = "0.12.3") +bazel_dep(name = "rules_rust", version = "0.68.1") +bazel_dep(name = "rules_shell", version = "0.6.1") +bazel_dep(name = "rules_python", version = "1.8.1") +bazel_dep(name = "rules_go", version = "0.60.0") +bazel_dep(name = "gazelle", version = "0.47.0") + +# https://github.com/bazelbuild/rules_go +go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk") +go_sdk.download(version = "1.26.0") + +go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps") +go_deps.from_file(go_mod = "//njdns:go.mod") +use_repo( + go_deps, + "com_github_hashicorp_hcl_v2", + "com_github_libdns_libdns", + "com_github_libdns_njalla", +) + +# https://github.com/bazelbuild/rules_rust +rust = use_extension("@rules_rust//rust:extensions.bzl", "rust") +rust.toolchain( + edition = "2024", + extra_rustc_flags = [ + "-Ctarget-cpu=native", + "-Clink-arg=-fuse-ld=mold", + ], + extra_target_triples = [], + versions = ["nightly/2026-03-07"], +) +use_repo(rust, "rust_toolchains") + +register_toolchains( + "@rust_toolchains//:rust_linux_x86_64__x86_64-unknown-linux-gnu__nightly", +) + +crate = use_extension("@rules_rust//crate_universe:extension.bzl", "crate") +crate.from_cargo( + name = "crates", + cargo_lockfile = "//:Cargo.lock", + manifests = [ + "//:alafont/Cargo.toml", + "//:diffamer/Cargo.toml", + "//:hakunadata/Cargo.toml", + "//:mack/Cargo.toml", + "//:plants-client/Cargo.toml", + "//:plants-common/Cargo.toml", + "//:plants-daemon/Cargo.toml", + "//:scores/Cargo.toml", + "//:virittaa/Cargo.toml", + "//:lastfm-csv-export/Cargo.toml", + "//:matchakey/Cargo.toml", + "//:battery-notify/Cargo.toml", + ], +) +use_repo(crate, "crates") + +# https://github.com/bazel-contrib/rules_python +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain( + python_version = "3.14", +) + +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.parse( + hub_name = "pypi", + python_version = "3.14", + requirements_lock = "//:requirements.out", +) +use_repo(pip, "pypi") + +# https://github.com/uber/hermetic_cc_toolchain +toolchains = use_extension("@hermetic_cc_toolchain//toolchain:ext.bzl", "toolchains") +use_repo(toolchains, "zig_sdk") + +register_toolchains( + "@hermetic_cc_toolchain//toolchain:all", +) + +# https://github.com/aherrmann/rules_zig +zig = use_extension("@rules_zig//zig:extensions.bzl", "zig") +zig.toolchain(zig_version = "0.15.2") + +ntsgui_deps = use_extension("//ntsgui:extension.bzl", "ntsgui_deps") +use_repo(ntsgui_deps, "ntsgui_raylib", "ntsgui_raylib_nuklear", "ntsgui_raylib_zig") + +digui_deps = use_extension("//digui:extension.bzl", "digui_deps") +use_repo(digui_deps, "digui_raylib", "digui_raylib_nuklear", "digui_raylib_zig") + +magdalena_deps = use_extension("//magdalena:extension.bzl", "magdalena_deps") +use_repo(magdalena_deps, "memcached", "zio", "zul") diff --git a/README.md b/README.md @@ -0,0 +1,49 @@ +# tools +I wanted to learn about [Bazel](https://bazel.build) but instead got addicted to watching my builds exit out with nothingburger error messages, one at a time. + +It was as if I was tilting in a high stakes poker game but instead of money I lost my sanity. And a lot of time. + +![](misc/hehe.png) + +I really like it though. + +### bootstrap +```bash +sudo pacman -Sy bazelisk mold +bazel fetch //... +``` + +### query +```bash +bazel query "kind('rust_', //...) except attr('name', '.*-(debug|profiling)$', //...)" +bazel query "kind('zig_', //...)" +bazel query "kind('rb_', //...)" +bazel query "kind('py_', //...)" +bazel query "kind('cc_', //...)" +bazel query "kind('sh_', //...)" +bazel query "kind('rb_', //...)" +``` + +### build +```bash +bazel build //magnolia +``` + +### deploy +> `bazel-bin` -> ``/opt/bazel-tools`` +```bash +sudo install -d -m 700 -o $USER /opt/bazel-tools +bazel run //magnolia:deploy +``` + +### misc +```bash +# updates root `Cargo.lock` (rust tools are nested in a workspace) +cargo generate-lockfile +# builds everything (including `debug` and `profiling` binaries) +bazel build //... +# updates python packages (`requirements.in` -> `requirements.out`) +bazel run //:requirements.update +# runs a `justfile` that contains all of the necessary commands +just +``` diff --git a/aaltomuoto/BUILD b/aaltomuoto/BUILD @@ -0,0 +1,21 @@ +load("@rules_python//python:defs.bzl", "py_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +py_binary( + name = "aaltomuoto", + srcs = ["aaltomuoto.py"], + main = "aaltomuoto.py", + python_version = "PY3", + deps = [ + "@pypi//mutagen", + "@pypi//numpy", + "@pypi//pillow", + "@pypi//scipy", + ], +) + +local_deploy( + name = "deploy", + srcs = [":aaltomuoto"], + copy_runfiles = True, +) diff --git a/aaltomuoto/README.md b/aaltomuoto/README.md @@ -0,0 +1,83 @@ +# Aaltomuoto + +Aaltomuoto generates frequency-colored waveforms that visualize the spectral content of audio files. Each vertical bar in the waveform represents the amplitude of the audio at that moment, with the color indicating the dominant frequency content. + +## Features + +- **15 Detailed Frequency Bands**: Audio is analyzed across 15 distinct frequency ranges for detailed spectral visualization +- **Vibrant Color Mapping**: Each frequency band is assigned a unique, vibrant color across the full spectrum +- **Anti-Aliased Rendering**: Smooth waveform rendering using 4x supersampling +- **Readable Text**: Track name and timeline markers with high contrast for visibility +- **Configurable Parameters**: Adjustable width, height, and detail level + +## Color Scheme + +Each color in the waveform represents a specific frequency range: + +| Color | Frequency Range | Description | +|-------|----------------|-------------| +| Deep Red | 0-40 Hz | Sub-bass (fundamental tones, kick drums) | +| Orange-Red | 40-120 Hz | Low bass (bass instruments, low percussion) | +| Orange | 120-250 Hz | Mid bass (warmth, body of bass instruments) | +| Yellow-Orange | 250-500 Hz | Lower low-mids (some warmth, texture) | +| Yellow-Green | 500-750 Hz | Low-mids (fullness, some vocal fundamentals) | +| Green | 750-1000 Hz | Lower midrange (clarity, presence) | +| Blue-Green | 1000-2000 Hz | Midrange (vocal presence, snare, guitar) | +| Blue | 2000-3000 Hz | Upper midrange (brightness, attack transients) | +| Indigo | 3000-4000 Hz | Lower high-mids (detail, clarity) | +| Purple | 4000-5000 Hz | High-mids (edge, sibilance) | +| Pink-Purple | 5000-6000 Hz | Upper high-mids (crispness) | +| Bright Pink | 6000-7000 Hz | Presence (air, sparkle) | +| Hot Pink | 7000-8000 Hz | Upper presence (fine detail) | +| Magenta | 8000-10000 Hz | Brilliance (brightness, shimmer) | +| Violet | 10000-20000 Hz | Air (ethereal highs, airiness) | + +## Usage + +```bash +python aaltomuoto.py <input_audio> [output.png] [width] [height] [pixels_per_second] +``` + +### Examples + +```bash +# Basic usage +python aaltomuoto.py track.flac waveform.png + +# Custom dimensions and detail +python aaltomuoto.py track.flac waveform.png 4000 350 500 + +# Higher detail level +python aaltomuoto.py track.flac waveform.png 4000 350 1000 +``` + +### Parameters + +- `<input_audio>`: Input audio file (required) +- `[output.png]`: Output image filename (default: waveform.png) +- `[width]`: Image width in pixels (default: 4000) +- `[height]`: Image height in pixels (default: 350) +- `[pixels_per_second]`: Detail level (higher = more detail, default: 500) + +## Technical Details + +- Uses FFT-based frequency analysis for accurate spectral decomposition +- Implements perceptually uniform color mapping using HSV color space +- Applies logarithmic amplitude scaling to enhance dynamic range +- Uses anti-aliasing for smooth waveform rendering +- Includes adaptive timeline markers based on track duration +- Renders text elements separately for optimal readability + +## Dependencies + +- Python 3.x +- numpy +- pillow +- mutagen +- scipy +- audiowaveform +- ffmpeg + +## License + +MIT License diff --git a/aaltomuoto/__pycache__/aaltomuoto.cpython-314.pyc b/aaltomuoto/__pycache__/aaltomuoto.cpython-314.pyc Binary files differ. diff --git a/aaltomuoto/aaltomuoto.py b/aaltomuoto/aaltomuoto.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +""" +Uses audiowaveform and ffmpeg to create frequency-colored waveforms +with 6 bands mapped to a blue-cyan color gradient: + - Sub-bass (< 60Hz) → Deep blue + - Bass (60-250Hz) → Blue + - Low-mid (250-500Hz) → Light blue + - Mid (500-2kHz) → Cyan + - High-mid (2-6kHz) → Bright cyan + - High (> 6kHz) → White/cyan +""" + +from __future__ import annotations + +import concurrent.futures +import json +import os +import subprocess +import sys +import tempfile +from typing import Any + +import mutagen +import numpy as np +from numpy.typing import NDArray +from PIL import Image, ImageDraw, ImageFont +from scipy import signal + +# Try to import numba for JIT acceleration (optional) +try: + from numba import njit, prange + + HAS_NUMBA = True +except ImportError: + HAS_NUMBA = False + # Fallback decorators when numba is not available + def njit(*args, **kwargs): + def decorator(func): + return func + + return decorator + + prange = range + +# Type aliases +FloatArray = NDArray[np.floating[Any]] +ColorTuple = tuple[int, int, int] + +# Frequency bands: (low_hz, high_hz, name) - based on standard audio engineering divisions with more detail +BANDS: list[tuple[int, int, str]] = [ + (0, 40, "subbass"), # Sub-bass (deep red) + (40, 120, "lowbass"), # Low bass (orange-red) + (120, 250, "midbass"), # Mid bass (orange) + (250, 500, "lowlowmid"), # Lower low-mids (yellow-orange) + (500, 750, "lowmid"), # Low-mids (yellow-green) + (750, 1000, "lowermid"), # Lower midrange (green) + (1000, 2000, "mid"), # Midrange (blue-green) + (2000, 3000, "uppermid"), # Upper midrange (blue) + (3000, 4000, "lowerhighmid"), # Lower high-mids (indigo) + (4000, 5000, "highmid"), # High-mids (purple) + (5000, 6000, "upperhighmid"), # Upper high-mids (pink-purple) + (6000, 7000, "presence"), # Presence (bright pink) + (7000, 8000, "upperpresence"), # Upper presence (hot pink) + (8000, 10000, "brilliance"), # Brilliance (magenta) + (10000, 20000, "air"), # Air (violet) +] + +# Pre-compute band names list for faster iteration +BAND_NAMES: list[str] = [name for _, _, name in BANDS] + + +def create_perceptually_uniform_colors(num_bands: int) -> list[ColorTuple]: + """Create vibrant, colorful colors using HSV color space.""" + colors = [] + # Spread hues across the entire color spectrum for maximum color variety + hue_start = 0 # Red (in degrees on HSV color wheel) + hue_end = 300 # Back to purple/pink (full spectrum) + + for i in range(num_bands): + # Calculate hue for this band + ratio = i / max(num_bands - 1, 1) # Avoid division by zero + hue = hue_start + (hue_end - hue_start) * ratio + + # Use high saturation for vivid colors + saturation = 0.85 # High saturation for vibrant colors + value = 0.9 # High value for bright colors + + # Convert HSV to RGB + rgb = hsv_to_rgb(hue / 360.0, saturation, value) + colors.append(rgb) + + return colors + + +def hsv_to_rgb(h: float, s: float, v: float) -> ColorTuple: + """Convert HSV color to RGB color tuple.""" + if s == 0.0: + r = g = b = int(v * 255) + return (r, g, b) + + i = int(h * 6) + f = (h * 6) - i + p = v * (1 - s) + q = v * (1 - s * f) + t = v * (1 - s * (1 - f)) + + i %= 6 + if i == 0: + r, g, b = v, t, p + elif i == 1: + r, g, b = q, v, p + elif i == 2: + r, g, b = p, v, t + elif i == 3: + r, g, b = p, q, v + elif i == 4: + r, g, b = t, p, v + else: + r, g, b = v, p, q + + return (int(r * 255), int(g * 255), int(b * 255)) + + +# Generate perceptually uniform colors for each band +band_names = [ + "subbass", + "lowbass", + "midbass", + "lowlowmid", + "lowmid", + "lowermid", + "mid", + "uppermid", + "lowerhighmid", + "highmid", + "upperhighmid", + "presence", + "upperpresence", + "brilliance", + "air", +] +perceptual_colors = create_perceptually_uniform_colors(len(band_names)) +BAND_COLORS: dict[str, NDArray[np.int64]] = { + name: np.array(color) for name, color in zip(band_names, perceptual_colors) +} + +# Waveform detail level +PIXELS_PER_SECOND = 500 + +# Layout constants +TITLE_HEIGHT = 25 +TIMELINE_HEIGHT = 20 + +# Color palette +BG_COLOR: ColorTuple = (5, 10, 20) +CENTER_LINE_COLOR: ColorTuple = (25, 50, 80) +QUIET_COLOR: ColorTuple = (40, 80, 120) +TITLE_COLOR: ColorTuple = (255, 255, 255) # Pure white for maximum visibility +TIMELINE_COLOR: ColorTuple = (100, 140, 180) # Brighter timeline +TICK_COLOR: ColorTuple = (200, 230, 255) # Bright white-blue for ticks +TEXT_COLOR: ColorTuple = (255, 255, 255) # Pure white for text + +# Amplitude threshold for "quiet" sections +QUIET_THRESHOLD = 8 + + +def get_track_info(file_path: str) -> str: + """Extract artist and title from audio file metadata.""" + try: + audio = mutagen.File(file_path, easy=True) + if audio is None: + return os.path.basename(file_path) + + artist_list = audio.get("artist") + title_list = audio.get("title") + + artist = str(artist_list[0]) if artist_list else "" + title = str(title_list[0]) if title_list else "" + + if artist and title: + return f"{artist} - {title}" + return title or artist or os.path.basename(file_path) + except Exception: + return os.path.basename(file_path) + + +def run_ffmpeg_filter(input_file: str, output_file: str, filter_str: str) -> None: + """Run ffmpeg with an audio filter.""" + subprocess.run( + [ + "ffmpeg", + "-y", + "-i", + input_file, + "-af", + filter_str, + "-ar", + "44100", + output_file, + ], + check=True, + capture_output=True, + ) + + +def generate_waveform_json( + input_file: str, + output_json: str, + pixels_per_second: int, +) -> dict[str, Any]: + """Generate waveform data using audiowaveform.""" + subprocess.run( + [ + "audiowaveform", + "-i", + input_file, + "-o", + output_json, + "--pixels-per-second", + str(pixels_per_second), + "--bits", + "8", + ], + check=True, + capture_output=True, + ) + with open(output_json) as f: + result: dict[str, Any] = json.load(f) + return result + + +def extract_amplitudes(data: dict[str, Any]) -> FloatArray: + """Extract absolute max amplitudes from waveform data.""" + raw_data: list[int] = data["data"] + raw = np.array(raw_data, dtype=np.int8) + mins = raw[0::2].astype(np.float64) + maxs = raw[1::2].astype(np.float64) + return np.maximum(np.abs(mins), np.abs(maxs)) + + +def normalize_amplitudes(amps: FloatArray, target_max: float = 127.0) -> FloatArray: + """Normalize amplitude values to improve dynamic range.""" + if len(amps) == 0: + return amps + + # Calculate the maximum amplitude in the array + max_val = np.max(amps) + + if max_val == 0: + return amps # Avoid division by zero + + # Apply logarithmic scaling to enhance quieter parts + # This helps bring out details in lower-amplitude sections + normalized = np.power(amps / max_val, 0.6) * target_max + + return normalized + + +def resize_array(arr: FloatArray, target_len: int) -> FloatArray: + """Resize array to target length using nearest-neighbor interpolation.""" + if len(arr) == target_len: + return arr + indices = np.linspace(0, len(arr) - 1, target_len).astype(np.intp) + return arr[indices] + + +def load_font(path: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Load a TrueType font, falling back to default if unavailable.""" + try: + return ImageFont.truetype(path, size) + except OSError: + return ImageFont.load_default() + + +@njit(parallel=True, cache=True) +def render_waveform_pixels( + aa_pixels_arr: FloatArray, + sample_amps: FloatArray, + band_values: FloatArray, + band_color_matrix: FloatArray, + aa_center_y: int, + aa_waveform_height: int, + title_height_scaled: int, + aa_height: int, + aa_width: int, + quiet_threshold: float, + quiet_color: tuple[int, int, int], + center_line_color: tuple[int, int, int], +) -> None: + """JIT-compiled function to render waveform pixels.""" + bar_heights = ((sample_amps / 127.0) * (aa_waveform_height // 2)).astype(np.int32) + + for x in prange(aa_width): + bar_height = bar_heights[x] + amp = sample_amps[x] + + if amp < quiet_threshold: + # Quiet section + y_start = max(aa_center_y - 2, title_height_scaled) + y_end = min(aa_center_y + 2, title_height_scaled + aa_waveform_height) + for y in range(y_start, y_end): + aa_pixels_arr[y, x, 0] = quiet_color[0] + aa_pixels_arr[y, x, 1] = quiet_color[1] + aa_pixels_arr[y, x, 2] = quiet_color[2] + else: + # Calculate color from band weights + total = 0.0 + for b in range(band_values.shape[1]): + total += band_values[x, b] + total += 1e-6 + + r, g, b = 0.0, 0.0, 0.0 + for band_idx in range(band_values.shape[1]): + weight = band_values[x, band_idx] / total + r += band_color_matrix[band_idx, 0] * weight + g += band_color_matrix[band_idx, 1] * weight + b += band_color_matrix[band_idx, 2] * weight + + # Draw colored bar + y_start = max(aa_center_y - bar_height, title_height_scaled) + y_end = min(aa_center_y + bar_height + 1, title_height_scaled + aa_waveform_height) + for y in range(y_start, y_end): + aa_pixels_arr[y, x, 0] = min(255, max(0, int(r))) + aa_pixels_arr[y, x, 1] = min(255, max(0, int(g))) + aa_pixels_arr[y, x, 2] = min(255, max(0, int(b))) + + # Draw center line + for x in range(aa_width): + aa_pixels_arr[aa_center_y, x, 0] = center_line_color[0] + aa_pixels_arr[aa_center_y, x, 1] = center_line_color[1] + aa_pixels_arr[aa_center_y, x, 2] = center_line_color[2] + + +def render_waveform( + original_amps: FloatArray, + band_amps: dict[str, FloatArray], + duration_seconds: float, + track_info: str, + output_file: str, + width: int = 4000, + height: int = 350, +) -> None: + """Render the CDJ-style waveform image with 8-band coloring.""" + n_samples = len(original_amps) + waveform_height = height - TIMELINE_HEIGHT - TITLE_HEIGHT + + # Normalize the original amplitudes to enhance dynamic range + normalized_original_amps = normalize_amplitudes(original_amps.copy()) + + # Normalize all band amplitudes to enhance dynamic range + normalized_band_amps: dict[str, FloatArray] = {} + for name, amps in band_amps.items(): + normalized_band_amps[name] = normalize_amplitudes(amps.copy()) + + # Resize all band arrays to match original + resized_bands: dict[str, FloatArray] = { + name: resize_array(amps, n_samples) + for name, amps in normalized_band_amps.items() + } + + # Anti-aliasing: render at higher resolution then downsample + aa_scale = 2 # Reduced from 4 for faster rendering + aa_width = width * aa_scale + aa_height = height * aa_scale + aa_waveform_height = waveform_height * aa_scale + aa_center_y = (TITLE_HEIGHT * aa_scale) + (aa_waveform_height // 2) + title_height_scaled = TITLE_HEIGHT * aa_scale + + # Create numpy array for pixel data + aa_pixels_arr = np.full((aa_height, aa_width, 3), BG_COLOR, dtype=np.float64) + + # Map samples to pixel columns + x_indices = np.linspace(0, n_samples - 1, aa_width).astype(np.intp) + + # Pre-compute band colors as float array + band_color_matrix = np.array([BAND_COLORS[name].astype(np.float64) for name in BAND_NAMES]) + + # Pre-compute all band values for all x positions (vectorized) + band_values = np.array([[resized_bands[name][idx] for name in BAND_NAMES] for idx in x_indices], dtype=np.float64) + + # Get sample amplitudes for mapped indices + sample_amps = normalized_original_amps[x_indices] + + # Use JIT-compiled rendering if available + if HAS_NUMBA: + render_waveform_pixels( + aa_pixels_arr, + sample_amps, + band_values, + band_color_matrix, + aa_center_y, + aa_waveform_height, + title_height_scaled, + aa_height, + aa_width, + QUIET_THRESHOLD, + QUIET_COLOR, + CENTER_LINE_COLOR, + ) + aa_pixels_arr = aa_pixels_arr.astype(np.uint8) + else: + # Fallback: vectorized numpy operations + bar_heights = ((sample_amps / 127.0) * (aa_waveform_height // 2)).astype(np.int32) + quiet_mask = sample_amps < QUIET_THRESHOLD + + # Calculate colors for all x positions at once + band_totals = np.sum(band_values, axis=1, keepdims=True) + 1e-6 + band_weights = band_values / band_totals + all_colors = np.clip(band_weights @ band_color_matrix, 0, 255).astype(np.uint8) + + for x in range(aa_width): + bar_height = bar_heights[x] + if quiet_mask[x]: + y_start = max(aa_center_y - aa_scale, title_height_scaled) + y_end = min(aa_center_y + aa_scale, title_height_scaled + aa_waveform_height) + aa_pixels_arr[y_start:y_end, x] = QUIET_COLOR + else: + y_start = max(aa_center_y - bar_height, title_height_scaled) + y_end = min(aa_center_y + bar_height + 1, title_height_scaled + aa_waveform_height) + aa_pixels_arr[y_start:y_end, x] = all_colors[x] + + aa_pixels_arr[aa_center_y, :] = CENTER_LINE_COLOR + aa_pixels_arr = aa_pixels_arr.astype(np.uint8) + + # Create PIL image from numpy array + aa_img = Image.fromarray(aa_pixels_arr, mode='RGB') + aa_draw = ImageDraw.Draw(aa_img) + + # Draw timeline + aa_timeline_y = title_height_scaled + aa_waveform_height + aa_pixels_arr[aa_timeline_y, :] = TIMELINE_COLOR + + # Choose appropriate tick interval based on duration + if duration_seconds <= 60: + tick_interval = 10 + elif duration_seconds <= 180: + tick_interval = 30 + else: + tick_interval = 60 + + # Draw time markers + aa_timeline_font = load_font( + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + 16 * aa_scale, + ) + + # Draw major ticks and labels + for i in range(int(duration_seconds / tick_interval) + 2): + time_sec = i * tick_interval + if time_sec > duration_seconds: + break + x = int((time_sec / duration_seconds) * (aa_width - 1)) + + tick_end = min(aa_timeline_y + (5 * aa_scale), aa_height) + aa_pixels_arr[aa_timeline_y:tick_end, x] = TICK_COLOR + + minutes = int(time_sec // 60) + seconds = int(time_sec % 60) + label = f"{minutes}:{seconds:02d}" + aa_draw.text( + (x + (3 * aa_scale), aa_timeline_y + (3 * aa_scale)), + label, + fill=TEXT_COLOR, + font=aa_timeline_font, + ) + + # Draw minor ticks + minor_tick_interval = tick_interval / 2 + for i in range(int(duration_seconds / minor_tick_interval) + 2): + time_sec = i * minor_tick_interval + if time_sec > duration_seconds or time_sec % tick_interval == 0: + continue + x = int((time_sec / duration_seconds) * (aa_width - 1)) + tick_end = min(aa_timeline_y + (3 * aa_scale), aa_height) + aa_pixels_arr[aa_timeline_y:tick_end, x] = TICK_COLOR + + # Downsample to final size + final_img = aa_img.resize((width, height), Image.LANCZOS) + + # Add text elements + draw = ImageDraw.Draw(final_img) + + # Draw title with background + title_font = load_font("/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", 32) + bbox = draw.textbbox((10, 5), track_info, font=title_font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + draw.rectangle([(5, 3), (text_width + 15, text_height + 7)], fill=(20, 30, 40, 180)) + draw.text((10, 5), track_info, fill=TITLE_COLOR, font=title_font) + + # Draw timeline markers + timeline_font = load_font("/usr/share/fonts/TTF/DejaVuSansMono.ttf", 20) + timeline_y = TITLE_HEIGHT + waveform_height + + for i in range(int(duration_seconds / tick_interval) + 2): + time_sec = i * tick_interval + if time_sec > duration_seconds: + break + x = int((time_sec / duration_seconds) * (width - 1)) + draw.line([(x, timeline_y), (x, min(timeline_y + 5, height - 1))], fill=TIMELINE_COLOR) + minutes = int(time_sec // 60) + seconds = int(time_sec % 60) + label = f"{minutes}:{seconds:02d}" + draw.text((x + 3, timeline_y + 3), label, fill=TEXT_COLOR, font=timeline_font) + + # Draw minor ticks + for i in range(int(duration_seconds / minor_tick_interval) + 2): + time_sec = i * minor_tick_interval + if time_sec > duration_seconds or time_sec % tick_interval == 0: + continue + x = int((time_sec / duration_seconds) * (width - 1)) + draw.line([(x, timeline_y), (x, min(timeline_y + 3, height - 1))], fill=TICK_COLOR) + + final_img.save(output_file) + print(output_file) + + +@njit(parallel=True, cache=True, fastmath=True) +def compute_band_energies( + magnitude: FloatArray, + band_indices: FloatArray, + n_bands: int, +) -> FloatArray: + """JIT-compiled function to compute energy for all frequency bands. + + Args: + magnitude: 2D array of shape (n_freqs, n_times) + band_indices: 2D array of shape (n_bands, 2) with [low_bin, high_bin] for each band + n_bands: Number of frequency bands + + Returns: + 2D array of shape (n_bands, n_times) with band energies + """ + n_times = magnitude.shape[1] + result = np.zeros((n_bands, n_times), dtype=np.float64) + + for band_idx in prange(n_bands): + low_bin = int(band_indices[band_idx, 0]) + high_bin = int(band_indices[band_idx, 1]) + for t in range(n_times): + energy = 0.0 + for freq_bin in range(low_bin, high_bin): + energy += magnitude[freq_bin, t] + result[band_idx, t] = energy + + return result + + +def process_frequency_band( + args: tuple[int, int, str, FloatArray, FloatArray, int, int] +) -> tuple[str, FloatArray]: + """Process a single frequency band (used for parallel processing).""" + low_hz, high_hz, name, magnitude, frequencies, window_size, hop_length = args + + # Find frequency bin indices corresponding to the band + low_bin = np.searchsorted(frequencies, low_hz) + high_bin = np.searchsorted(frequencies, high_hz) + + # Extract energy in this frequency band + band_energy = np.sum(magnitude[low_bin:high_bin], axis=0) + + return (name, np.abs(band_energy)) + + +def perform_frequency_analysis( + audio_file: str, sample_rate: int = 44100, max_workers: int | None = None +) -> dict[str, FloatArray]: + """Perform FFT-based frequency analysis to get more accurate band amplitudes. + + Uses multi-threading and JIT compilation for optimal performance. + """ + # Load audio file using ffmpeg to get raw PCM data + with tempfile.NamedTemporaryFile(suffix=".raw", delete=False) as temp_raw: + subprocess.run( + [ + "ffmpeg", + "-y", + "-i", + audio_file, + "-f", + "s16le", + "-ar", + str(sample_rate), + "-ac", + "1", # Convert to mono for simplicity + temp_raw.name, + ], + check=True, + capture_output=True, + ) + + # Read the raw audio data + raw_audio = np.frombuffer(open(temp_raw.name, "rb").read(), dtype=np.int16) + audio_data = raw_audio.astype(np.float32) / 32768.0 # Normalize to [-1, 1] + + # Clean up temporary file + os.unlink(temp_raw.name) + + # Define window size and hop length for STFT + window_size = 2048 # Smaller window for faster processing + hop_length = window_size // 4 + + # Perform Short-Time Fourier Transform using optimized scipy.signal + frequencies, _, stft_matrix = signal.stft( + audio_data, + fs=sample_rate, + nperseg=window_size, + noverlap=window_size - hop_length, + window="hann", + padded=False, + boundary=None, + ) + + # Calculate magnitude spectrogram + magnitude = np.abs(stft_matrix).astype(np.float64) + + # Pre-compute frequency bin indices for all bands (cached) + n_bands = len(BANDS) + band_indices = np.zeros((n_bands, 2), dtype=np.float64) + for i, (low_hz, high_hz, _) in enumerate(BANDS): + band_indices[i, 0] = np.searchsorted(frequencies, low_hz) + band_indices[i, 1] = np.searchsorted(frequencies, high_hz) + + # Use JIT-compiled function if available, otherwise use parallel processing + if HAS_NUMBA: + # Compute all band energies at once with JIT + all_band_energies = compute_band_energies(magnitude, band_indices, n_bands) + band_amps: dict[str, FloatArray] = {} + for i, (_, _, name) in enumerate(BANDS): + band_amps[name] = np.abs(all_band_energies[i]) + else: + # Fallback to parallel processing without JIT + band_args = [ + (low_hz, high_hz, name, magnitude, frequencies, window_size, hop_length) + for low_hz, high_hz, name in BANDS + ] + + band_amps = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(process_frequency_band, args): args[2] + for args in band_args + } + for future in concurrent.futures.as_completed(futures): + name, band_energy = future.result() + band_amps[name] = band_energy + + return band_amps + + +def build_ffmpeg_filter(low_hz: int, high_hz: int) -> str: + """Build ffmpeg filter string for a frequency band.""" + if low_hz == 0: + return f"lowpass=f={high_hz}" + if high_hz >= 20000: + return f"highpass=f={low_hz}" + return f"highpass=f={low_hz},lowpass=f={high_hz}" + + +def main() -> None: + """Main entry point.""" + if len(sys.argv) < 2: + print( + "aaltomuoto <input_audio> [output.png] [width] [height] [pixels_per_second]" + ) + print("aaltomuoto track.flac waveform.png 4000 350 500") + print("Additional options:") + print(" pixels_per_second: Controls waveform detail level (default: 500)") + print(" width: Image width in pixels (default: 4000)") + print(" height: Image height in pixels (default: 350)") + print(" threads: Number of threads for frequency analysis (default: CPU count)") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else "waveform.png" + width = int(sys.argv[3]) if len(sys.argv) > 3 else 4000 + height = int(sys.argv[4]) if len(sys.argv) > 4 else 350 + pixels_per_second = int(sys.argv[5]) if len(sys.argv) > 5 else PIXELS_PER_SECOND + max_workers = int(sys.argv[6]) if len(sys.argv) > 6 else None + + if not os.path.exists(input_file): + print(f"Error: File '{input_file}' not found") + sys.exit(1) + + print(input_file) + print(f"{output_file} ({width}x{height})") + print(f"{pixels_per_second} pixels/second, {len(BANDS)} frequency bands") + + # Generate waveform data using audiowaveform for the overall shape + with tempfile.TemporaryDirectory() as tmpdir: + orig_json = os.path.join(tmpdir, "original.json") + orig_data = generate_waveform_json(input_file, orig_json, pixels_per_second) + orig_amps = extract_amplitudes(orig_data) + + # Calculate duration + samples_per_pixel: int = int(orig_data.get("samples_per_pixel", 256)) + sample_rate: int = int(orig_data.get("sample_rate", 44100)) + raw_data: list[int] = orig_data["data"] + n_pixels = len(raw_data) // 2 + duration_seconds = float(n_pixels * samples_per_pixel) / sample_rate + + # Perform more accurate frequency analysis using FFT (multi-threaded) + band_amps = perform_frequency_analysis(input_file, sample_rate, max_workers) + + # Get track metadata + track_info = get_track_info(input_file) + print(track_info) + + # Render waveform + render_waveform( + orig_amps, + band_amps, + duration_seconds, + track_info, + output_file, + width, + height, + ) + + +if __name__ == "__main__": + main() diff --git a/alafont/BUILD b/alafont/BUILD @@ -0,0 +1,7 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "alafont", + deps = all_crate_deps(), +) diff --git a/alafont/Cargo.toml b/alafont/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "alafont" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +toml_edit = "0.25" diff --git a/alafont/src/main.rs b/alafont/src/main.rs @@ -0,0 +1,43 @@ +use std::fs; +use std::path::PathBuf; +use clap::Parser; +use toml_edit::{DocumentMut, value}; + +#[derive(Parser, Debug)] +#[command(name = "alacritty_font")] +struct Args { + #[arg(short, long)] + font: String, + + #[arg(short, long)] + size: f64, + + #[arg(short, long)] + config: Option<PathBuf>, +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let args = Args::parse(); + + let config_path = if let Some(path) = args.config { + path + } else { + let home = std::env::var("HOME")?; + PathBuf::from(home).join(".config/alacritty/alacritty.toml") + }; + + let content = fs::read_to_string(&config_path)?; + let mut doc = content.parse::<DocumentMut>()?; + + doc["font"]["size"] = value(args.size); + + doc["font"]["normal"]["family"] = value(&args.font); + doc["font"]["bold"]["family"] = value(&args.font); + doc["font"]["italic"]["family"] = value(&args.font); + + fs::write(&config_path, doc.to_string())?; + + println!("Updated font to '{}' with size {}", args.font, args.size); + + Ok(()) +} diff --git a/bandeno/.dockerignore b/bandeno/.dockerignore @@ -0,0 +1 @@ +.gitignore diff --git a/bandeno/Containerfile b/bandeno/Containerfile @@ -0,0 +1,19 @@ +FROM docker.io/denoland/deno:alpine + +WORKDIR /app + +RUN chown deno:deno /app + +USER deno + +COPY --chown=deno:deno deno.json deno.lock ./ + +RUN deno install + +COPY --chown=deno:deno . . + +RUN deno cache main.ts + +EXPOSE 8000 + +CMD ["run", "--allow-net", "main.ts"] diff --git a/bandeno/deno.json b/bandeno/deno.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "dev": "deno run --allow-net --watch main.ts", + "run": "deno run --allow-net main.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.19", + "linkedom": "npm:linkedom@^0.18.12" + } +} diff --git a/bandeno/deno.lock b/bandeno/deno.lock @@ -0,0 +1,108 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.12", + "npm:linkedom@~0.18.12": "0.18.12" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + } + }, + "npm": { + "boolbase@1.0.0": { + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, + "css-select@5.2.2": { + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dependencies": [ + "boolbase", + "css-what", + "domhandler", + "domutils", + "nth-check" + ] + }, + "css-what@6.2.2": { + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" + }, + "cssom@0.5.0": { + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" + }, + "dom-serializer@2.0.0": { + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": [ + "domelementtype", + "domhandler", + "entities@4.5.0" + ] + }, + "domelementtype@2.3.0": { + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler@5.0.3": { + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": [ + "domelementtype" + ] + }, + "domutils@3.2.2": { + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": [ + "dom-serializer", + "domelementtype", + "domhandler" + ] + }, + "entities@4.5.0": { + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "entities@7.0.1": { + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" + }, + "html-escaper@3.0.3": { + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, + "htmlparser2@10.1.0": { + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dependencies": [ + "domelementtype", + "domhandler", + "domutils", + "entities@7.0.1" + ] + }, + "linkedom@0.18.12": { + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "dependencies": [ + "css-select", + "cssom", + "html-escaper", + "htmlparser2", + "uhyphen" + ] + }, + "nth-check@2.1.1": { + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": [ + "boolbase" + ] + }, + "uhyphen@0.2.0": { + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.19", + "npm:linkedom@~0.18.12" + ] + } +} diff --git a/bandeno/deploy b/bandeno/deploy @@ -0,0 +1,2 @@ +#!/bin/bash +podman build -t bandeno . && podman save bandeno | ssh clam "sudo podman load && sudo systemctl restart bandeno" diff --git a/bandeno/main.ts b/bandeno/main.ts @@ -0,0 +1,139 @@ +import { DOMParser } from "linkedom"; + +const port = Number(Deno.env.get("PORT") || "8000"); +const hostname = Deno.env.get("HOST") || "127.0.0.1"; + +Deno.serve({ port, hostname }, async (req: Request) => { + const url = new URL(req.url); + const artist = url.searchParams.get("artist"); + const fan = url.searchParams.get("fan"); + + if (artist) { + return handleArtist(artist); + } else if (fan) { + return handleFan(fan); + } else { + return Response.json( + { + error: "Our princess is in another castle!", + }, + { + status: 400, + }, + ); + } +}); + +async function handleArtist(artist: string) { + const res = await fetch(`https://${artist}.bandcamp.com/music`); + + if (!res.ok) { + return Response.json( + { + error: `Could not fetch data for ${artist}`, + }, + { + status: res.status, + }, + ); + } + + const html = await res.text(); + const document = new DOMParser().parseFromString(html, "text/html"); + const items = Array.from(document.querySelectorAll("#music-grid li")).map( + // deno-lint-ignore no-explicit-any + (item: any) => { + const titleElement = item.querySelector(".title"); + const title = titleElement + ? titleElement.textContent.replace(/(\r\n|\n|\r)/gm, "").trim() + : "Unknown Title"; + + const linkElement = item.querySelector("a"); + const href = linkElement ? linkElement.getAttribute("href") : ""; + const url = href + ? href.startsWith("http") + ? href + : `https://${artist}.bandcamp.com${href}` + : ""; + + return { + id: url, + url, + title, + content_html: title, + }; + }, + ); + + return Response.json({ + version: "https://jsonfeed.org/version/1.1", + title: `${artist}`, + home_page_url: `https://${artist}.bandcamp.com`, + items, + }); +} + +async function handleFan(fan: string) { + const targetUrl = `https://bandcamp.com/${fan}`; + const res = await fetch(targetUrl); + + if (!res.ok) { + return Response.json( + { + error: `Could not fetch data for ${fan}`, + }, + { + status: res.status, + }, + ); + } + + const html = await res.text(); + const document = new DOMParser().parseFromString(html, "text/html"); + + const items = Array.from( + document.querySelectorAll("#collection-grid .collection-item-container"), + // deno-lint-ignore no-explicit-any + ).map((item: any) => { + const titleElement = item.querySelector(".collection-item-title"); + let title = "Unknown Title"; + if (titleElement) { + title = titleElement.textContent + .replace(/(\r\n|\n|\r)/gm, "") + .replace("(gift given)", "") + .trim(); + } + + const artistElement = item.querySelector(".collection-item-artist"); + const artistName = artistElement + ? artistElement.textContent + .replace(/(\r\n|\n|\r)/gm, "") + .trim() + .replace(/^by\s+/, "") + : "Unknown Artist"; + + const linkElement = item.querySelector(".item-link"); + const href = linkElement ? linkElement.getAttribute("href") : ""; + + const imgElement = item.querySelector(".collection-item-art"); + const imgSrc = imgElement ? imgElement.getAttribute("src") : ""; + + const fullTitle = `${title} by ${artistName}`; + const description = `<img src="${imgSrc}" /><br/>${title} by ${artistName}`; + + return { + id: href, + url: href, + title: fullTitle, + content_html: description, + image: imgSrc, + }; + }); + + return Response.json({ + version: "https://jsonfeed.org/version/1.1", + title: `${fan}`, + home_page_url: targetUrl, + items, + }); +} diff --git a/bandeno/todo.txt b/bandeno/todo.txt @@ -0,0 +1 @@ +- add makefile diff --git a/bazel/BUILD b/bazel/BUILD @@ -0,0 +1 @@ +exports_files(["local-deploy.bzl"]) diff --git a/bazel/local-deploy.bzl b/bazel/local-deploy.bzl @@ -0,0 +1,110 @@ +""" +Rule for local *nix deployment of a binary build artifact. + +This is useful for local development but is not intended for production deployments. +""" + +def _local_deploy_impl(ctx): + target = ctx.attr.target + copy_runfiles = ctx.attr.copy_runfiles + + # We use a set to avoid duplicates if multiple targets provide the same file + files_map = {} + transitive_runfiles = [] + + for t in ctx.attr.srcs: + if DefaultInfo in t: + info = t[DefaultInfo] + transitive_runfiles.append(info.default_runfiles) + + # If it's an executable, we prefer that. + if info.files_to_run.executable: + exe = info.files_to_run.executable + files_map[exe.short_path] = exe + else: + # Otherwise take all files + for f in info.files.to_list(): + files_map[f.short_path] = f + + # Sort for determinism + sorted_paths = sorted(files_map.keys()) + + final_files = [] + + script_content = [ + "#!/bin/bash", + "set -e", + "TARGET_DIR=%s" % target, + "sudo mkdir -p \"$TARGET_DIR\"", + "", + "# Locate the runfiles directory of this deploy script", + "if [ -n \"$RUNFILES_DIR\" ]; then", + " RUNFILES_SOURCE=\"$RUNFILES_DIR\"", + "elif [ -d \"${0}.runfiles\" ]; then", + " RUNFILES_SOURCE=\"${0}.runfiles\"", + "else", + " RUNFILES_SOURCE=\"\"", + "fi", + "" + ] + + for path in sorted_paths: + # If "foo.sh" and "foo" exist, skip "foo.sh". + is_shadowed = False + for ext in [".sh", ".py"]: + if path.endswith(ext): + wrapper = path[:-len(ext)] + if wrapper in files_map: + is_shadowed = True + break + + if is_shadowed: + continue + + f = files_map[path] + final_files.append(f) + + # Add copy command + script_content.extend([ + "SRC=\"%s\"" % f.short_path, + "REAL=$(readlink -f \"$SRC\")", + "echo \"Copying $SRC to $TARGET_DIR\"", + "sudo cp \"$REAL\" \"$TARGET_DIR/$(basename \"$SRC\")\"", + ]) + + # Copy runfiles if enabled + if copy_runfiles: + script_content.extend([ + "if [ -n \"$RUNFILES_SOURCE\" ]; then", + " DEST_RUNFILES=\"$TARGET_DIR/$(basename \"$SRC\").runfiles\"", + " echo \"Copying runfiles to $DEST_RUNFILES\"", + " sudo rm -rf \"$DEST_RUNFILES\"", + " sudo cp -r \"$RUNFILES_SOURCE\" \"$DEST_RUNFILES\"", + "fi", + "", + ]) + + ctx.actions.write( + output = ctx.outputs.executable, + is_executable = True, + content = "\n".join(script_content) + ) + + runfiles = ctx.runfiles(files = final_files) + for r in transitive_runfiles: + runfiles = runfiles.merge(r) + + return DefaultInfo( + executable = ctx.outputs.executable, + runfiles = runfiles, + ) + +local_deploy = rule( + executable = True, + implementation = _local_deploy_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "target": attr.string(default = "~/.local/share/tools"), + "copy_runfiles": attr.bool(default = False), + }, +) diff --git a/bazel/rust.bzl b/bazel/rust.bzl @@ -0,0 +1,121 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") +load("//bazel:local-deploy.bzl", "local_deploy") + +def rust_app( + name, + srcs = None, + deps = [], + test_deps = [], + edition = "2024", + rustc_flags = [], + visibility = ["//visibility:public"], + **kwargs): + """ + Defines a standard Rust application with optimized, debug, and profiling builds, + along with unit tests and a local deployment target. + """ + if srcs == None: + srcs = native.glob(["src/**/*.rs"]) + + common_flags = [ + "-Ctarget-cpu=native", + "-Clink-arg=-fuse-ld=mold", + ] + rustc_flags + + rust_binary( + name = name, + srcs = srcs, + edition = edition, + rustc_flags = common_flags + [ + "-Copt-level=3", + "-Ccodegen-units=1", + ], + visibility = visibility, + deps = deps, + **kwargs + ) + + rust_binary( + name = name + "-debug", + srcs = srcs, + edition = edition, + rustc_flags = common_flags, + visibility = visibility, + deps = deps, + **kwargs + ) + + rust_binary( + name = name + "-profiling", + srcs = srcs, + edition = edition, + rustc_flags = common_flags + [ + "-Copt-level=3", + "-Cdebuginfo=2", + "-Ccodegen-units=64", + "-Clto=off", + ], + visibility = visibility, + deps = deps, + **kwargs + ) + + rust_test( + name = name + "_test", + crate = ":" + name, + edition = edition, + rustc_flags = common_flags, + deps = test_deps, + ) + + local_deploy( + name = "deploy", + srcs = [":" + name], + ) + +def rust_lib( + name, + crate_name = None, + srcs = None, + deps = [], + test_deps = [], + edition = "2024", + rustc_flags = [], + visibility = ["//visibility:public"], + **kwargs): + """ + Defines a standard Rust library with optimized and debug builds, + along with unit tests. + """ + if srcs == None: + srcs = native.glob(["src/**/*.rs"]) + + if crate_name == None: + crate_name = name + + common_flags = [ + "-Ctarget-cpu=native", + "-Clink-arg=-fuse-ld=mold", + ] + rustc_flags + + rust_library( + name = name, + crate_name = crate_name, + srcs = srcs, + edition = edition, + rustc_flags = common_flags + [ + "-Copt-level=3", + "-Ccodegen-units=1", + ], + visibility = visibility, + deps = deps, + **kwargs + ) + + rust_test( + name = name + "_test", + crate = ":" + name, + edition = edition, + rustc_flags = common_flags, + deps = test_deps, + ) diff --git a/diffamer/BUILD b/diffamer/BUILD @@ -0,0 +1,7 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "diffamer", + deps = all_crate_deps(), +) diff --git a/diffamer/Cargo.toml b/diffamer/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffamer" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +tempfile = "3.10" +clap = { version = "4.5", features = ["derive"] } +natord = "1.0" +similar = "2.7.0" +console = "0.16" diff --git a/diffamer/src/main.rs b/diffamer/src/main.rs @@ -0,0 +1,377 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use console::style; +use similar::{ChangeTag, TextDiff}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None, arg_required_else_help = true)] +struct Cli { + #[arg(long, required = true)] + host: String, + + #[arg(long, required = true)] + local: PathBuf, + + #[arg(long)] + remote: Option<PathBuf>, + + #[arg(long, conflicts_with = "remote")] + same_as_local: bool, + + #[arg(long)] + reverse: bool, + + #[arg(long)] + sync: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum FileStatus { + Created, + Updated, + Unchanged, +} + +#[derive(Debug)] +struct FileSyncWorker { + host_alias: String, + local_path: PathBuf, + remote_path: PathBuf, + sync: bool, + reverse: bool, +} + +impl FileSyncWorker { + fn new( + host_alias: String, + local_path: PathBuf, + remote_path: PathBuf, + sync: bool, + reverse: bool, + ) -> Self { + Self { + host_alias, + local_path, + remote_path, + sync, + reverse, + } + } + + fn sync(&self) -> Result<()> { + fs::create_dir_all(&self.local_path).context("Failed to create local files directory")?; + + let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; + let temp_path = temp_dir.path(); + + if self.reverse { + println!("Syncing files to {}", self.host_alias); + } else { + println!("Syncing files from {}", self.host_alias); + } + + let remote_src = format!("{}:{}/", self.host_alias, self.remote_path.display()); + + let status = Command::new("rsync") + .arg("-az") + .arg(&remote_src) + .arg(temp_path) + .status() + .context("Failed to execute rsync")?; + + if !status.success() { + anyhow::bail!("Rsync failed with status: {status}"); + } + + if self.reverse { + self.process_reverse_sync(temp_path) + } else { + self.process_normal_sync(temp_path) + } + } + + fn process_normal_sync(&self, temp_path: &Path) -> Result<()> { + let mut created = 0; + let mut updated = 0; + let mut unchanged = 0; + let mut errors = 0; + + let entries = fs::read_dir(temp_path).context("Failed to read temp directory")?; + + // Process files sequentially + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + match self.process_files(&path) { + Ok(FileStatus::Created) => created += 1, + Ok(FileStatus::Updated) => updated += 1, + Ok(FileStatus::Unchanged) => unchanged += 1, + Err(_) => errors += 1, + } + } + } + + println!("\nSync completed:"); + println!(" Created: {created}"); + println!(" Updated: {updated}"); + println!(" Unchanged: {unchanged}"); + if errors > 0 { + println!(" Errors: {errors}"); + } + + Ok(()) + } + + fn process_reverse_sync(&self, remote_temp_path: &Path) -> Result<()> { + let staging_dir = tempfile::tempdir().context("Failed to create staging directory")?; + let staging_path = staging_dir.path(); + + let mut created = 0; + let mut updated = 0; + let mut unchanged = 0; + let mut errors = 0; + + let entries = fs::read_dir(&self.local_path).context("Failed to read local directory")?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + match self.process_local_file(&path, remote_temp_path, staging_path) { + Ok(FileStatus::Created) => created += 1, + Ok(FileStatus::Updated) => updated += 1, + Ok(FileStatus::Unchanged) => unchanged += 1, + Err(_) => errors += 1, + } + } + } + + println!("\nSync completed (Reverse):"); + println!(" Created: {created}"); + println!(" Updated: {updated}"); + println!(" Unchanged: {unchanged}"); + if errors > 0 { + println!(" Errors: {errors}"); + } + + if self.sync { + println!("Uploading to {}:{}/", self.host_alias, self.remote_path.display()); + let remote_dest = format!("{}:{}/", self.host_alias, self.remote_path.display()); + let status = Command::new("rsync") + .arg("-az") + .arg(format!("{}/", staging_path.display())) + .arg(&remote_dest) + .status() + .context("Failed to execute rsync upload")?; + + if !status.success() { + anyhow::bail!("Rsync upload failed with status: {status}"); + } + } + + Ok(()) + } + + fn process_files(&self, temp_file_path: &Path) -> Result<FileStatus> { + let filename = temp_file_path + .file_name() + .and_then(|n| n.to_str()) + .context("Invalid filename")?; + + let remote_content = fs::read_to_string(temp_file_path) + .with_context(|| format!("Error reading temp file {filename}"))?; + + let remote_entries: Vec<String> = remote_content.lines().map(ToString::to_string).collect(); + + self.merge_and_write(filename, remote_entries) + } + + fn process_local_file( + &self, + local_file_path: &Path, + remote_temp_path: &Path, + staging_path: &Path, + ) -> Result<FileStatus> { + let filename = local_file_path + .file_name() + .and_then(|n| n.to_str()) + .context("Invalid filename")?; + + let local_content = fs::read_to_string(local_file_path)?; + let local_entries: Vec<String> = local_content.lines().map(ToString::to_string).collect(); + + let remote_file_path = remote_temp_path.join(filename); + let remote_exists = remote_file_path.exists(); + + let remote_entries = if remote_exists { + let c = fs::read_to_string(&remote_file_path)?; + c.lines().map(ToString::to_string).collect() + } else { + Vec::new() + }; + + let final_entries = if remote_exists { + Self::merge_entries(local_entries, remote_entries) + } else { + local_entries + }; + + let new_content = if final_entries.is_empty() { + String::new() + } else { + format!("{}\n", final_entries.join("\n")) + }; + + let original_remote_content = if remote_exists { + fs::read_to_string(&remote_file_path)? + } else { + String::new() + }; + + if original_remote_content == new_content { + return Ok(FileStatus::Unchanged); + } + + if !self.sync { + println!("Diff for {filename} (Reverse):"); + let diff = TextDiff::from_lines(&original_remote_content, &new_content); + for change in diff.iter_all_changes() { + let (sign, style) = match change.tag() { + ChangeTag::Delete => ("-", style(change).red()), + ChangeTag::Insert => ("+", style(change).green()), + ChangeTag::Equal => (" ", style(change)), + }; + print!("{sign}{style}"); + } + return Ok(if remote_exists { + FileStatus::Updated + } else { + FileStatus::Created + }); + } + + // Write to staging + let staging_file = staging_path.join(filename); + fs::write(&staging_file, new_content)?; + + if remote_exists { + println!("Updating: {filename}"); + Ok(FileStatus::Updated) + } else { + println!("Creating: {filename}"); + Ok(FileStatus::Created) + } + } + + fn merge_and_write(&self, filename: &str, remote_entries: Vec<String>) -> Result<FileStatus> { + let local_files = self.local_path.join(filename); + let exists = local_files.exists(); + + let final_entries = if exists { + let local_content = fs::read_to_string(&local_files)?; + let local_entries = local_content.lines().map(ToString::to_string).collect(); + Self::merge_entries(local_entries, remote_entries) + } else { + // For new files, copy remote content as-is without filtering or sorting + remote_entries + }; + + let new_content = if final_entries.is_empty() { + String::new() + } else { + format!("{}\n", final_entries.join("\n")) + }; + + if exists { + let current_content = fs::read_to_string(&local_files)?; + if current_content == new_content { + return Ok(FileStatus::Unchanged); + } + } + + if !self.sync { + let current_content = if exists { + fs::read_to_string(&local_files)? + } else { + String::new() + }; + + println!("Diff for {filename}:"); + let diff = TextDiff::from_lines(&current_content, &new_content); + for change in diff.iter_all_changes() { + let (sign, style) = match change.tag() { + ChangeTag::Delete => ("-", style(change).red()), + ChangeTag::Insert => ("+", style(change).green()), + ChangeTag::Equal => (" ", style(change)), + }; + print!("{sign}{style}"); + } + + return Ok(if exists { + FileStatus::Updated + } else { + FileStatus::Created + }); + } + + let status = if exists { + println!("Updating: {filename}"); + FileStatus::Updated + } else { + println!("Creating: {filename}"); + FileStatus::Created + }; + + if !new_content.is_empty() || exists { + fs::write(&local_files, new_content)?; + } + + Ok(status) + } + + fn merge_entries(local: Vec<String>, remote: Vec<String>) -> Vec<String> { + // Union of local and remote, preserving unique entries + let mut seen = HashSet::new(); + let mut result = Vec::new(); + + // Add all local entries first + for entry in local { + if !entry.trim().is_empty() && seen.insert(entry.clone()) { + result.push(entry); + } + } + + // Add remote entries that aren't already seen + for entry in remote { + if !entry.trim().is_empty() && seen.insert(entry.clone()) { + result.push(entry); + } + } + + result.sort_by(|a, b| natord::compare(a, b)); + result + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let remote = if cli.same_as_local { + cli.local.clone() + } else { + cli.remote + .context("--remote or --same-as-local must be specified")? + }; + + let syncer = FileSyncWorker::new(cli.host, cli.local, remote, cli.sync, cli.reverse); + syncer.sync()?; + + Ok(()) +} diff --git a/diggah/BUILD b/diggah/BUILD @@ -0,0 +1,19 @@ +load("@rules_python//python:defs.bzl", "py_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +py_binary( + name = "diggah", + srcs = ["diggah.py"], + main = "diggah.py", + python_version = "PY3", + deps = [ + "@pypi//fire", + "@pypi//paramiko", + ], +) + +local_deploy( + name = "deploy", + srcs = [":diggah"], + copy_runfiles = True, +) diff --git a/diggah/README.md b/diggah/README.md @@ -0,0 +1,74 @@ +# Diggah + +Diggah is a command-line tool to find files and directories that were modified within a specific time range. It supports local file system searches as well as remote execution via ssh. + +## Installation + +Ensure you have `fire`, and `paramiko` installed locally. + +## Usage + +The main command is `search`. You can run the script directly: + +```bash +python3 diggah.py search [ARGS] +``` + +### Examples + +**1. Search valid relative to today (last 24h) in current directory:** +```bash +python3 diggah.py search --today +``` + +**2. Search in a specific path:** +```bash +python3 diggah.py search --today --path=/var/log +``` + +**3. Search for a specific week or month:** +```bash +# Find modifications in the 1st week of January 2025 +python3 diggah.py search --year=2025 --month=1 --week=1 + +# Find modifications in the whole month of January 2025 +python3 diggah.py search --year=2025 --month=1 +``` + +**4. Search with specific dates:** +```bash +python3 diggah.py search --start_date=2025-01-01 --end_date=2025-01-07 +``` + +**5. Include files in output (default is dirs only):** +```bash +python3 diggah.py search --today --all +``` + +**6. Output Relative Paths:** +```bash +# Output paths relative to the search directory (strip the prefix) +# e.g., /long/path/to/foo/bar -> foo/bar +python3 diggah.py search --today -r --path=/long/path/to +``` + +**7. Write to File (`-w`):** +```bash +# Save output to a file with relative path (YYYY-MM-DD.txt) +python3 diggah.py search --relative --today -w + +# Save to weekly files (1_01_2025.txt ... 4_01_2025.txt) +python3 diggah.py search --year=2025 --month=1 -w +``` + +**7. Remote Execution:** +```bash +# Execute the search on a remote server +python3 diggah.py search --today --host=user@example.com --path=/opt/app/logs +``` + +### Help +To see all available options: +```bash +python3 diggah.py search --help +``` diff --git a/diggah/diggah.py b/diggah/diggah.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +import os +import sys +from collections.abc import Iterable +from datetime import datetime, timedelta +from typing import cast + +import fire +import paramiko + + +class DateCalculator: + """Handles date validation and range calculations.""" + + @staticmethod + def validate_date(date_str: str) -> datetime: + try: + return datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + raise ValueError(f"'{date_str}' is incorrent, please use YYYY-MM-DD") + + def get_range_from_week( + self, year: int, month: int, week: int + ) -> tuple[datetime, datetime]: + """ + Calculates date range for a specific week. + """ + first_of_month = datetime(int(year), int(month), 1) + weeks_delta = timedelta(weeks=int(week) - 1) + start_date = first_of_month + weeks_delta + end_date = start_date + timedelta(days=7) + return start_date, end_date + + def get_range_from_month(self, year: int, month: int) -> tuple[datetime, datetime]: + """ + Calculates date range for a specific month. + """ + start_date = datetime(int(year), int(month), 1) + if month == 12: + end_date = datetime(int(year) + 1, 1, 1) + else: + end_date = datetime(int(year), int(month) + 1, 1) + return start_date, end_date + + def get_range_from_year(self, year: int) -> tuple[datetime, datetime]: + """ + Calculates date range for a specific year. + """ + start_date = datetime(int(year), 1, 1) + end_date = datetime(int(year) + 1, 1, 1) + return start_date, end_date + + +class FileSystemSearcher: + """Handles searching the file system for modified items.""" + + def find_items( + self, + start_ts: float | None, + end_ts: float | None, + include_files: bool = False, + root: str = ".", + ): + """ + Yields paths of items modified between start_ts and end_ts (inclusive). + """ + for dirpath, _, filenames in os.walk(root): + if self._is_modified_in_range(dirpath, start_ts, end_ts): + yield dirpath + + if include_files: + for filename in filenames: + filepath = os.path.join(dirpath, filename) + if self._is_modified_in_range(filepath, start_ts, end_ts): + yield filepath + + def _is_modified_in_range( + self, path: str, start_ts: float | None, end_ts: float | None + ) -> bool: + try: + mtime = os.path.getmtime(path) + if start_ts is not None and mtime <= start_ts: + return False + if end_ts is not None and mtime >= end_ts: + return False + return True + except OSError: + return False + + +class RemoteExecutor: + """Handles executing the script on a remote host via SSH using Paramiko.""" + + def execute( + self, + host_str: str, + path: str, + start_dt: datetime | None, + end_dt: datetime | None, + include_files: bool, + ) -> Iterable[str]: + """ + Executes a find command on the remote host and yields the results. + """ + username = None + hostname = host_str + if "@" in host_str: + username, hostname = host_str.split("@", 1) + + # Load SSH config + ssh_config = paramiko.SSHConfig() + user_config_file = os.path.expanduser("~/.ssh/config") + if os.path.exists(user_config_file): + with open(user_config_file) as f: + ssh_config.parse(f) + + user_config = ssh_config.lookup(hostname) + + if "hostname" in user_config: + hostname = user_config["hostname"] + + if username is None and "user" in user_config: + username = user_config["user"] + + port = int(user_config.get("port", 22)) + key_filename = user_config.get("identityfile") + + cmd_parts = ["find", path] + + if not include_files: + cmd_parts.extend(["-type", "d"]) + + if start_dt: + start_str = start_dt.strftime("%Y-%m-%d %H:%M:%S") + cmd_parts.extend(["-newermt", f"'{start_str}'"]) + + if end_dt: + end_str = end_dt.strftime("%Y-%m-%d %H:%M:%S") + cmd_parts.extend(["!", "-newermt", f"'{end_str}'"]) + + cmd = " ".join(cmd_parts) + + client = paramiko.SSHClient() + client.load_system_host_keys() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + sock = None + if "proxycommand" in user_config: + sock = paramiko.ProxyCommand(user_config["proxycommand"]) + + client.connect( + hostname=hostname, + username=username, + port=port, + key_filename=key_filename, + sock=sock, + ) + + stdin, stdout, stderr = client.exec_command(cmd) + stdin.close() + + # Read stderr first/async or just handle stdout for now. + for line in cast(Iterable[str], stdout): + line_str = str(line).strip() + if line_str: + yield line_str + + for line in cast(Iterable[str], stderr): + print(str(line), end="", file=sys.stderr) + + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + sys.exit(exit_status) + + except Exception as e: + print(f"({hostname}): {e}", file=sys.stderr) + sys.exit(1) + finally: + client.close() + + +class Diggah: + """ + Diggah: A tool to find modified files in a date range. + """ + + def search( + self, + start_date: str | None = None, + end_date: str | None = None, + year: int | None = None, + month: int | None = None, + week: int | None = None, + today: bool = False, + files: bool = False, + all: bool = False, + path: str = ".", + dry_run: bool = False, + host: str | None = None, + relative: bool = False, + output: str | bool = False, + ): + """ + Search for files/directories modified within a specified timeframe. + + Args: + start_date: Start date (YYYY-MM-DD). + end_date: End date (YYYY-MM-DD). + year: Year for searching. + month: Month for searching (optional). + week: Week number for searching (optional). + today: If True, search the last 24 hours. + files: If True, include files in output (default is dirs only). + all: If True, show all finds regardless of time. + path: Directory to search in (default: "."). + dry_run: If True, print calculated dates and exit. + host: SSH host to run the command on (e.g., user@example.com). + relative: If True, output paths relative to the search root. + output: If True or a path, write output to file(s) instead of stdout. + If a path is provided, writes to that file. + If True (flag only), writes to default filename(s). + For month search with default filename, this splits output into 4 weekly files. + """ + + ranges = self._determine_search_ranges( + start_date, end_date, year, month, week, today, all, output + ) + + if not ranges: + fire.Fire(self.search, command=["--help"], name="diggah search") + return + + for start_dt, end_dt, outfile in ranges: + if dry_run: + self._print_dry_run( + start_dt, end_dt, path, files, host, relative, output, outfile + ) + else: + self._run_search(start_dt, end_dt, path, files, host, relative, outfile) + + def _determine_search_ranges( + self, + start_date: str | None, + end_date: str | None, + year: int | None, + month: int | None, + week: int | None, + today: bool, + all_time: bool, + output: str | bool, + ) -> list[tuple[datetime | None, datetime | None, str | None]]: + """ + Determines the list of (start_dt, end_dt, outfile) tuples based on arguments. + """ + calc = DateCalculator() + ranges: list[tuple[datetime | None, datetime | None, str | None]] = [] + + custom_outfile = os.path.expanduser(output) if isinstance(output, str) else None + + if all_time: + if any([today, year, start_date, end_date]): + raise ValueError("cannot combine with other time constraints") + outfile = custom_outfile or ("all.txt" if output else None) + ranges.append((None, None, outfile)) + + elif today: + if start_date or year: + raise ValueError("cannot combine with specific dates") + now = datetime.now() + start_dt = now - timedelta(days=1) + end_dt = now + outfile = custom_outfile or ( + f"{now.strftime('%Y-%m-%d')}.txt" if output else None + ) + ranges.append((start_dt, end_dt, outfile)) + + elif year is None and month is not None: + # Default to current year if month is specified but year is not + year = datetime.now().year + + if year is not None: + year = int(year) + if start_date: + raise ValueError("cannot combine with positional dates") + + if month is None: + # Year only + start_dt, end_dt = calc.get_range_from_year(year) + outfile = custom_outfile or (f"{year}.txt" if output else None) + ranges.append((start_dt, end_dt, outfile)) + else: + month = int(month) + if week is not None: + # Specific week + week = int(week) + start_dt, end_dt = calc.get_range_from_week(year, month, week) + outfile = custom_outfile or ( + f"{week}_{month:02d}_{year}.txt" if output else None + ) + ranges.append((start_dt, end_dt, outfile)) + else: + # Whole month + if custom_outfile: + start_dt, end_dt = calc.get_range_from_month(year, month) + ranges.append((start_dt, end_dt, custom_outfile)) + elif output: + # Split by week if writing to default files + for i in range(1, 5): + s, e = calc.get_range_from_week(year, month, i) + outfile = f"{i}_{month:02d}_{year}.txt" + ranges.append((s, e, outfile)) + else: + start_dt, end_dt = calc.get_range_from_month(year, month) + outfile = None + ranges.append((start_dt, end_dt, outfile)) + + elif start_date and end_date: + start_dt = calc.validate_date(start_date) + end_dt = calc.validate_date(end_date) + outfile = custom_outfile or ( + f"{start_date}_{end_date}.txt" if output else None + ) + ranges.append((start_dt, end_dt, outfile)) + + return ranges + + def _print_dry_run( + self, + start_dt: datetime | None, + end_dt: datetime | None, + path: str, + include_files: bool, + host: str | None, + relative: bool, + output: str | bool, + outfile: str | None, + ): + print(f"Start: {start_dt if start_dt else 'None (All Time)'}") + print(f"End: {end_dt if end_dt else 'None (All Time)'}") + print(f"Path: {path}") + print(f"Include Files: {include_files}") + if host: + print(f"Host: {host}") + print("Mode: Remote (find command)") + else: + print("Mode: Local (python walk)") + if relative: + print("Output: Relative paths") + if output: + print(f"Write to: {outfile}") + + def _run_search( + self, + start_dt: datetime | None, + end_dt: datetime | None, + path: str, + include_files: bool, + host: str | None, + relative: bool, + outfile: str | None, + ): + results: Iterable[str] + if host: + results = RemoteExecutor().execute( + host, path, start_dt, end_dt, include_files + ) + else: + start_ts = start_dt.timestamp() if start_dt else None + end_ts = end_dt.timestamp() if end_dt else None + results = FileSystemSearcher().find_items( + start_ts, + end_ts, + include_files=include_files, + root=path, + ) + + f = None + if outfile: + try: + f = open(outfile, "w") + print(f"{outfile}") + except IOError as e: + print(f"opening output file: {e} failed", file=sys.stderr) + return + + try: + for item in results: + if relative: + item = os.path.relpath(item, start=path) + + if f: + _ = f.write(item + "\n") + else: + print(item) + finally: + if f: + f.close() + + +def main(): + try: + fire.Fire(Diggah()) + except ValueError as e: + print(f"{e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/diggah/pyrightconfig.json b/diggah/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "reportMissingTypeStubs": true, + "reportUnknownMemberType": true, + "reportUnknownVariableType": true +} diff --git a/f2aif/BUILD b/f2aif/BUILD @@ -0,0 +1,15 @@ +load("@rules_python//python:defs.bzl", "py_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +py_binary( + name = "f2aif", + srcs = ["f2aif.py"], + main = "f2aif.py", + python_version = "PY3", +) + +local_deploy( + name = "deploy", + srcs = [":f2aif"], + copy_runfiles = True, +) diff --git a/f2aif/f2aif.py b/f2aif/f2aif.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import os +import subprocess +import argparse +from pathlib import Path + + +def check_artwork(file_path): + """Check if a file contains artwork.""" + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v", + "-show_entries", + "stream=codec_type", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(file_path), + ], + capture_output=True, + text=True, + ) + return "video" in result.stdout + + +def convert_flac_to_aiff(input_path, delete_original=True): + """Convert a FLAC file to AIFF format.""" + output_path = input_path.with_suffix(".aif") + + if output_path.exists(): + print(f"Skipping {input_path} - {output_path} already exists") + return True + + print(f"Converting {input_path} to {output_path}") + + try: + subprocess.run( + [ + "ffmpeg", + "-i", + str(input_path), + "-c:a", + "pcm_s16be", # Use 16-bit PCM encoding + "-map", + "0:a", # Map audio stream + "-map", + "0:v?", # Map video (cover art) stream if it exists + "-map_metadata", + "0", # Copy all metadata + "-write_id3v2", + "1", # Write ID3v2 tags + "-id3v2_version", + "3", # Use ID3v2.3 for better compatibility + "-f", + "aiff", + str(output_path), + ], + check=True, + capture_output=True, + ) + + had_artwork = check_artwork(input_path) + if had_artwork: + if check_artwork(output_path): + print(f"Successfully converted {input_path} (artwork preserved)") + else: + print(f"Warning: Artwork may not have transferred for {input_path}") + else: + print(f"Successfully converted {input_path}") + + if delete_original: + try: + input_path.unlink() + print(f"Deleted original file: {input_path}") + except Exception as e: + print(f"Warning: Could not delete original file {input_path}: {str(e)}") + + return True + + except subprocess.CalledProcessError as e: + print(f"Error converting {input_path}:") + print(f"ffmpeg error: {e.stderr.decode()}") + return False + except Exception as e: + print(f"Unexpected error converting {input_path}: {str(e)}") + return False + + +def main(): + parser = argparse.ArgumentParser( + description="Convert FLAC files to AIFF format recursively." + ) + parser.add_argument("folder_path", help="Path to the folder containing FLAC files") + parser.add_argument( + "--keep-original", + action="store_true", + help="Keep original FLAC files after conversion (default: delete)", + ) + args = parser.parse_args() + + start_dir = Path(args.folder_path).resolve() + if not start_dir.exists(): + print(f"Error: The directory '{start_dir}' does not exist.") + return + if not start_dir.is_dir(): + print(f"Error: '{start_dir}' is not a directory.") + return + + converted = 0 + deleted = 0 + errors = 0 + + for flac_file in start_dir.rglob("*.flac"): + try: + success = convert_flac_to_aiff(flac_file, not args.keep_original) + if success: + converted += 1 + if not args.keep_original and not flac_file.exists(): + deleted += 1 + except Exception as e: + print(f"Failed to process {flac_file}: {str(e)}") + errors += 1 + + print("\nConversion Complete!") + print(f"Successfully converted: {converted} files") + if not args.keep_original: + print(f"Original files deleted: {deleted} files") + if errors > 0: + print(f"Errors encountered: {errors} files") + + +if __name__ == "__main__": + main() diff --git a/hakunadata/BUILD b/hakunadata/BUILD @@ -0,0 +1,7 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "hakunadata", + deps = all_crate_deps(), +) diff --git a/hakunadata/Cargo.toml b/hakunadata/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "hakunadata" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.4", features = ["derive"] } +lofty = "0.23" +reqwest = { version = "0.13", features = ["json", "native-tls", "query"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tempfile = "3.8" +tokio = { version = "1.0", features = ["full", "macros", "rt-multi-thread"] } +url = "2.5" +walkdir = "2" diff --git a/hakunadata/README.md b/hakunadata/README.md @@ -0,0 +1,51 @@ +# hakunadata + +`hakunadata` is an opinionated tool that fetches music metadata (genres and labels) from Discogs or MusicBrainz and writes them to audio files. + +## Usage + +### Fetching metadata for an artist and album (proposal only) + +```bash +hakunadata --artist 'The Beatles' --album 'Abbey Road' +``` + +### Reading tags from a file + +This will fetch metadata based on the artist and album tags in the file and show you what tags would be written, without actually changing the file. + +```bash +hakunadata --read file.flac +``` + +### Writing tags to a file + +This will fetch metadata and write it to the file's tags. + +```bash +hakunadata --write file.flac +``` + +### Processing a directory recursively + +You can also provide a directory path to process all supported audio files (`.flac`, `.mp3`, `.ogg`, `.m4a`) within that directory and its subdirectories. + +```bash +# Show proposed tags for all files in a directory +hakunadata --read /path/to/music + +# Write tags for all files in a directory +hakunadata --write /path/to/music +``` + +## API Credentials + +The tool can use either Discogs or MusicBrainz. + +- **Discogs (Default, recommended):** For better results, especially for genre and label information, a Discogs token is recommended. You can get one from your Discogs developer settings. + + ```bash + export DISCOGS_TOKEN="your_discogs_token_here" + ``` + +- **MusicBrainz (Fallback):** If the `DISCOGS_TOKEN` environment variable is not set, the tool will fall back to using the MusicBrainz API, which does not require authentication. Results might be less detailed. diff --git a/hakunadata/src/context.rs b/hakunadata/src/context.rs @@ -0,0 +1,7 @@ +use crate::fetchers::discogs::DiscogsClient; +use crate::fetchers::musicbrainz::MusicBrainzClient; + +pub struct AppContext { + pub mb_client: Option<MusicBrainzClient>, + pub discogs_client: Option<DiscogsClient>, +} diff --git a/hakunadata/src/fetchers/discogs.rs b/hakunadata/src/fetchers/discogs.rs @@ -0,0 +1,82 @@ +use anyhow::{Context, Result}; +use reqwest::header; +use serde::Deserialize; + +const USER_AGENT: &str = "hakunadata/0.1.0 ( miro@haravara.org )"; + +pub struct DiscogsClient { + client: reqwest::Client, + token: Option<String>, +} + +impl DiscogsClient { + pub fn new() -> Result<Self> { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::USER_AGENT, + header::HeaderValue::from_static(USER_AGENT), + ); + + let client = reqwest::Client::builder() + .default_headers(headers) + .build()?; + + let token = std::env::var("DISCOGS_TOKEN").ok(); + + Ok(Self { client, token }) + } + + pub async fn fetch_metadata( + &self, + artist: &str, + release: &str, + ) -> Result<Option<DiscogsResult>> { + let url = "https://api.discogs.com/database/search"; + let mut query = vec![ + ("type", "release"), + ("artist", artist), + ("release_title", release), + ]; + + let token_string; + if let Some(t) = &self.token { + token_string = t.clone(); + query.push(("token", &token_string)); + } + + let response = self + .client + .get(url) + .query(&query) + .send() + .await + .context("Failed to send Discogs request")?; + + if !response.status().is_success() { + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + eprintln!("DISCOGS_TOKEN is required"); + } + } + return Ok(None); + } + + let search_result: DiscogsSearchResponse = response.json().await?; + + Ok(search_result.results.into_iter().next()) + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct DiscogsSearchResponse { + pub results: Vec<DiscogsResult>, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct DiscogsResult { + pub genre: Option<Vec<String>>, + pub style: Option<Vec<String>>, + pub label: Option<Vec<String>>, +} diff --git a/hakunadata/src/fetchers/mod.rs b/hakunadata/src/fetchers/mod.rs @@ -0,0 +1,2 @@ +pub mod discogs; +pub mod musicbrainz; diff --git a/hakunadata/src/fetchers/musicbrainz.rs b/hakunadata/src/fetchers/musicbrainz.rs @@ -0,0 +1,134 @@ +use anyhow::{Context, Result}; +use reqwest::header; +use serde::Deserialize; + +const USER_AGENT: &str = "hakunadata/0.1.0 ( miro@haravara.org )"; + +pub struct MusicBrainzClient { + client: reqwest::Client, +} + +impl MusicBrainzClient { + pub fn new() -> Result<Self> { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::USER_AGENT, + header::HeaderValue::from_static(USER_AGENT), + ); + + let client = reqwest::Client::builder() + .default_headers(headers) + .build()?; + + Ok(Self { client }) + } + + pub async fn fetch_genres(&self, artist: &str, release: &str) -> Result<Vec<String>> { + // First search for the release group to get a broader set of tags, or specific release. + // Let's try searching for "release" first as it is more specific, but release-group often has the tags. + // Actually, searching for release-group is usually better for genres as they adhere to the abstract album. + + let query = format!("artist:\"{artist}\" AND release:\"{release}\""); + let url = "https://musicbrainz.org/ws/2/release"; + + let response = self + .client + .get(url) + .query(&[ + ("query", query.as_str()), + ("fmt", "json"), + ("limit", "1"), // Just take the best match + ]) + .send() + .await + .context("Failed to send MusicBrainz request")?; + + if !response.status().is_success() { + // It's okay if we don't find it, but we should log it? + // For now just return empty. + return Ok(vec![]); + } + + let search_result: MbSearchResponse = response.json().await?; + + // If we found a release, let's fetch its group to get tags, OR if the release itself has tags (usually not in search result). + // The search result usually contains some info. But to get tags we might need to look up. + // Wait, the search result for 'release' DOES return a list of releases. + // If we want tags, we often need to include 'tags' in an 'inc' parameter for a direct lookup, but for search we get what we get. + // Actually, MB search API returns tags if present? Usually not. + // Better flow: + // 1. Search for release-group (or release). + // 2. Get ID. + // 3. Lookup release-group with inc=tags. + + if let Some(release_match) = search_result.releases.first() { + // If we have a release-group ID, use that. + if let Some(rg) = &release_match.release_group { + return self.lookup_release_group_tags(&rg.id).await; + } + } + + Ok(vec![]) + } + + async fn lookup_release_group_tags(&self, id: &str) -> Result<Vec<String>> { + let url = format!("https://musicbrainz.org/ws/2/release-group/{id}"); + let response = self + .client + .get(&url) + .query(&[("fmt", "json"), ("inc", "tags+genres")]) + .send() + .await?; + + if !response.status().is_success() { + return Ok(vec![]); + } + + let rg: MbReleaseGroup = response.json().await?; + let mut genres = Vec::new(); + + if let Some(tags) = rg.tags { + for tag in tags { + // Filter out garbage tags? maybe later. + genres.push(tag.name); + } + } + if let Some(genres_list) = rg.genres { + for g in genres_list { + genres.push(g.name); + } + } + + Ok(genres) + } +} + +// --- Serde Structs --- + +#[derive(Deserialize, Debug)] +struct MbSearchResponse { + #[serde(default)] + releases: Vec<MbRelease>, +} + +#[derive(Deserialize, Debug)] +struct MbRelease { + #[serde(rename = "release-group")] + release_group: Option<MbReleaseGroupRef>, +} + +#[derive(Deserialize, Debug)] +struct MbReleaseGroupRef { + id: String, +} + +#[derive(Deserialize, Debug)] +struct MbReleaseGroup { + tags: Option<Vec<MbTag>>, + genres: Option<Vec<MbTag>>, +} + +#[derive(Deserialize, Debug)] +struct MbTag { + name: String, +} diff --git a/hakunadata/src/main.rs b/hakunadata/src/main.rs @@ -0,0 +1,121 @@ +mod context; +mod fetchers; +mod metadata; +mod tagging; + +use anyhow::{Context, Result}; +use clap::Parser; +use context::AppContext; +use fetchers::discogs::DiscogsClient; +use fetchers::musicbrainz::MusicBrainzClient; +use metadata::fetch::process_query; +use std::path::Path; +use tagging::{print_metadata, process_file}; +use walkdir::WalkDir; + +static EXAMPLES: &str = r"EXAMPLES: + Fetch metadata for an artist and album: + hakunadata --artist 'Djrum' --album 'Under Tangled Silence' + + Preview tags for a single file: + hakunadata --read file.mp3 + + Write tags to a single file: + hakunadata --write file.mp3 + + Show proposed tags for all files in a directory: + hakunadata --read /path/to/music + + Write tags to all files in a directory: + hakunadata --write /path/to/music"; + +#[derive(Parser, Debug)] +#[command( + author, + version, + about, + long_about = None, + after_help = EXAMPLES +)] +struct Args { + /// Artist name + #[arg(long)] + artist: Option<String>, + + /// Album name + #[arg(long)] + album: Option<String>, + + /// File(s) to process + #[arg(required_unless_present_any = ["artist", "album"])] + files: Vec<String>, + + /// Write tags to file + #[arg(short, long, default_value_t = false)] + write: bool, + + /// Read tags from file and show what would be written + #[arg(short, long, default_value_t = true)] + read: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let (discogs_client, mb_client) = if std::env::var("DISCOGS_TOKEN").is_ok() { + ( + Some(DiscogsClient::new().context("Failed to init Discogs client")?), + None, + ) + } else { + ( + None, + Some(MusicBrainzClient::new().context("Failed to init MusicBrainz client")?), + ) + }; + + let ctx = AppContext { + mb_client, + discogs_client, + }; + + if !args.files.is_empty() { + for path_str in args.files { + let path = Path::new(&path_str); + if !path.exists() { + eprintln!("Path does not exist: {}", path.display()); + continue; + } + + let mut files_to_process = Vec::new(); + if path.is_dir() { + // Fix redundant_closure: |e| e.ok() -> Result::ok + for entry in WalkDir::new(path).into_iter().filter_map(Result::ok) { + if entry.file_type().is_file() + && entry + .path() + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| matches!(ext, "flac" | "mp3" | "ogg" | "m4a")) + { + files_to_process.push(entry.path().to_path_buf()); + } + } + } else { + files_to_process.push(path.to_path_buf()); + } + + for file_path in files_to_process { + if let Err(e) = process_file(&ctx, &file_path, args.read, args.write).await { + eprintln!("Failed to process file {}: {e:?}", file_path.display()); + } + } + } + } else if let (Some(artist), Some(album)) = (args.artist, args.album) { + let result = process_query(&ctx, &artist, &album).await?; + print_metadata(&result); + } + + Ok(()) +} diff --git a/hakunadata/src/metadata/fetch.rs b/hakunadata/src/metadata/fetch.rs @@ -0,0 +1,74 @@ +use crate::context::AppContext; +use crate::metadata::{genres, labels, subgenres}; +use anyhow::Result; + +pub struct FetchedMetadata { + pub genres: Vec<String>, + pub subgenres: Vec<String>, + pub labels: Vec<String>, +} + +pub async fn process_query(ctx: &AppContext, artist: &str, album: &str) -> Result<FetchedMetadata> { + // Run fetchers concurrently (or rather, run whichever is enabled) + let discogs_future = async { + if let Some(client) = &ctx.discogs_client { + client.fetch_metadata(artist, album).await + } else { + Ok(None) + } + }; + + let mb_future = async { + if let Some(client) = &ctx.mb_client { + client.fetch_genres(artist, album).await + } else { + Ok(vec![]) + } + }; + + let (discogs_res, mb_res) = tokio::join!(discogs_future, mb_future); + + let mut genres = std::collections::HashSet::new(); + let mut subgenres = std::collections::HashSet::new(); + let mut labels = std::collections::HashSet::new(); + + // Process Discogs + if let Ok(Some(data)) = discogs_res { + let g = genres::process(&data); + for item in g { + genres.insert(item); + } + + let s = subgenres::process(&data); + for item in s { + subgenres.insert(item); + } + + let l = labels::process(&data); + for item in l { + labels.insert(item); + } + } + + // Process MusicBrainz + if let Ok(mb_genres) = mb_res { + for g in mb_genres { + genres.insert(g); + } + } + + let mut sorted_genres: Vec<_> = genres.into_iter().collect(); + sorted_genres.sort(); + + let mut sorted_subgenres: Vec<_> = subgenres.into_iter().collect(); + sorted_subgenres.sort(); + + let mut sorted_labels: Vec<_> = labels.into_iter().collect(); + sorted_labels.sort(); + + Ok(FetchedMetadata { + genres: sorted_genres, + subgenres: sorted_subgenres, + labels: sorted_labels, + }) +} diff --git a/hakunadata/src/metadata/genres.rs b/hakunadata/src/metadata/genres.rs @@ -0,0 +1,16 @@ +use crate::fetchers::discogs::DiscogsResult; +use std::collections::HashSet; + +pub fn process(result: &DiscogsResult) -> Vec<String> { + let mut genres = HashSet::<String>::new(); + + if let Some(g) = &result.genre { + for g_item in g { + genres.insert(g_item.clone()); + } + } + + let mut sorted_genres: Vec<_> = genres.into_iter().collect(); + sorted_genres.sort(); + sorted_genres +} diff --git a/hakunadata/src/metadata/labels.rs b/hakunadata/src/metadata/labels.rs @@ -0,0 +1,16 @@ +use crate::fetchers::discogs::DiscogsResult; +use std::collections::HashSet; + +pub fn process(result: &DiscogsResult) -> Vec<String> { + let mut labels = HashSet::<String>::new(); + + if let Some(l) = &result.label { + for label in l { + labels.insert(label.clone()); + } + } + + let mut sorted_labels: Vec<_> = labels.into_iter().collect(); + sorted_labels.sort(); + sorted_labels +} diff --git a/hakunadata/src/metadata/mod.rs b/hakunadata/src/metadata/mod.rs @@ -0,0 +1,4 @@ +pub mod fetch; +pub mod genres; +pub mod labels; +pub mod subgenres; diff --git a/hakunadata/src/metadata/subgenres.rs b/hakunadata/src/metadata/subgenres.rs @@ -0,0 +1,16 @@ +use crate::fetchers::discogs::DiscogsResult; +use std::collections::HashSet; + +pub fn process(result: &DiscogsResult) -> Vec<String> { + let mut subgenres = HashSet::<String>::new(); + + if let Some(s) = &result.style { + for style in s { + subgenres.insert(style.clone()); + } + } + + let mut sorted_subgenres: Vec<_> = subgenres.into_iter().collect(); + sorted_subgenres.sort(); + sorted_subgenres +} diff --git a/hakunadata/src/tagging.rs b/hakunadata/src/tagging.rs @@ -0,0 +1,204 @@ +use crate::context::AppContext; +use crate::metadata::fetch::{process_query, FetchedMetadata}; +use anyhow::{Context, Result}; +use lofty::config::WriteOptions; +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::{ItemKey, Tag}; +use std::fs::File; +use std::path::{Component, Path}; + +/// Process a music file to read or write metadata tags +pub async fn process_file(ctx: &AppContext, path: &Path, read: bool, write: bool) -> Result<()> { + if !read && !write { + return Ok(()); + } + + // Normalize the path to remove relative components like './' and '../' + let normalized_path = normalize_path(path); + let abs_path = std::env::current_dir().map_or_else( + |_| normalized_path.clone(), + |cwd| cwd.join(&normalized_path), + ); + + let path_display = abs_path.display(); + + // Check if file exists + if !abs_path.exists() { + return Err(anyhow::anyhow!("File does not exist: {path_display}")); + } + + // Check if file is empty + let metadata = abs_path + .metadata() + .with_context(|| format!("Failed to read metadata for: {path_display}"))?; + + if metadata.len() == 0 { + eprintln!("Warning: File is empty, skipping: {path_display}"); + return Ok(()); + } + + // Read the file and extract current metadata + let (mut tagged_file, artist, album) = { + // Use the Probe API which can read from readers + let file = File::open(&abs_path) + .with_context(|| format!("Failed to open file: {path_display}"))?; + let mut probe = Probe::new(file); + + // Hint the file type based on extension if possible + if let Some(file_type) = path + .extension() + .and_then(|e| e.to_str()) + .and_then(lofty::file::FileType::from_ext) + { + probe = probe.set_file_type(file_type); + } + + let mut tagged_file = probe + .read() + .with_context(|| format!("Failed to read tags from {path_display}"))?; + + let tag = tagged_file + .primary_tag_mut() + .context("No primary tag found")?; + + let artist = tag.artist().context("Artist not found")?.to_string(); + let album = tag.album().context("Album not found")?.to_string(); + + (tagged_file, artist, album) + }; // file handle goes out of scope here + + println!("Processing: {artist} - {album}"); + + let result = process_query(ctx, &artist, &album).await?; + + if read { + print_proposed_tags(&result); + } + + if write { + // Get mutable reference to tag for writing + let tag = tagged_file + .primary_tag_mut() + .context("No primary tag found")?; + write_tags(tag, &result); + + // Create a backup of the original file before modifying it to prevent data loss on failure + let backup_path = abs_path.with_extension(format!( + "backup.{}", + abs_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("bak") + )); + + // Copy the original file to backup + std::fs::copy(&abs_path, &backup_path) + .with_context(|| format!("Failed to create backup file: {}", backup_path.display()))?; + + // Attempt to save the modified file to the original location + let save_result = tagged_file.save_to_path(&abs_path, WriteOptions::default()); + + match save_result { + Ok(()) => { + // Success: remove the backup file + let _ = std::fs::remove_file(&backup_path); // Ignore errors when removing backup + } + Err(e) => { + // Failure: restore from backup before returning the error + if std::path::Path::exists(&backup_path) { + if let Err(restore_err) = std::fs::copy(&backup_path, &abs_path) { + eprintln!( + "ERROR: Failed to restore from backup after write failure: {restore_err}", + ); + eprintln!( + "WARNING: Original file may be corrupted. Backup preserved at: {}", + backup_path.display() + ); + return Err(anyhow::anyhow!( + "Also failed to restore from backup: {restore_err}" + ) + .context(e)); + } + eprintln!("Restored file from backup after write failure"); + let _ = std::fs::remove_file(&backup_path); // Clean up backup after successful restore + } + return Err(anyhow::anyhow!("Failed to write tags to file").context(e)); + } + } + + println!("Tags written to {path_display}"); + } + + Ok(()) +} + +/// Helper function to normalize a path by removing relative components +fn normalize_path(path: &Path) -> std::path::PathBuf { + let mut result = std::path::PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(prefix_component) => result.push(prefix_component.as_os_str()), + Component::RootDir => result.push("/"), + Component::CurDir => {} // Skip './' + Component::ParentDir => { + result.pop(); // Handle '../' by popping the last component + } + Component::Normal(os_string) => result.push(os_string), + } + } + + result +} + +/// Print the proposed tags to stdout +fn print_proposed_tags(metadata: &FetchedMetadata) { + println!("Proposed tags:"); + let genre_str = metadata.genres.join("/"); + println!(" Genre: {genre_str}"); + + if let Some(label) = metadata.labels.first() { + println!(" Label: {label}"); + } +} + +/// Write metadata to the tag +fn write_tags(tag: &mut Tag, metadata: &FetchedMetadata) { + let genre_str = metadata.genres.join("/"); + tag.insert_text(ItemKey::Genre, genre_str); + + if let Some(label) = metadata.labels.first() { + tag.insert_text(ItemKey::Label, label.clone()); + } +} + +/// Print metadata to stdout +pub fn print_metadata(result: &FetchedMetadata) { + if result.genres.is_empty() { + println!("Genres: (none)"); + } else { + println!("Genres:"); + for genre in &result.genres { + println!(" {genre}"); + } + } + + if result.subgenres.is_empty() { + println!("Subgenres: (none)"); + } else { + println!("Subgenres:"); + for subgenre in &result.subgenres { + println!(" {subgenre}"); + } + } + + if result.labels.is_empty() { + println!("Label: (none)"); + } else { + println!("Label:"); + for label in &result.labels { + println!(" {label}"); + } + } +} diff --git a/hue/.bundle/config b/hue/.bundle/config @@ -0,0 +1,2 @@ +--- +BUNDLE_PATH: "vendor/bundle" diff --git a/hue/.gitignore b/hue/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/hue/Gemfile b/hue/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec diff --git a/hue/Gemfile.lock b/hue/Gemfile.lock @@ -0,0 +1,72 @@ +PATH + remote: . + specs: + hue (0.1.0) + json (~> 2.18) + matrix + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + bigdecimal (4.0.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + hashdiff (1.2.1) + json (2.18.1) + matrix (0.4.3) + public_suffix (7.0.5) + rake (13.3.1) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + hue! + rake + rspec (>= 3.0) + webmock (>= 3.0) + +CHECKSUMS + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + hue (0.1.0) + json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 + +BUNDLED WITH + 4.0.7 diff --git a/hue/README.md b/hue/README.md @@ -0,0 +1,318 @@ +# Hue - Ruby Library and CLI for Philips Hue + +This is a Ruby library and command-line interface for controlling the [Philips Hue](http://www.meethue.com) lighting system. + +## WARNING + +All of this is very experimental and could permanently damage your awesome (but ridiculously expensive) lightbulbs. As such, exercise extreme caution. + +## Installation + +To begin using, install the ruby gem (this requires ruby 3.0+): + +```bash +$ gem install hue +``` + +This will install the `hue` command. + +## Usage + +### Command Line Interface + +To check the bridge state, simply type: + +```bash +$ hue +``` + +If this is the first time the command is run, it will scan for bridges on the network and attempt to register the application. Follow the on-screen instructions to do so. + +#### Register + +You can also explicitly register the application with: + +```bash +$ hue register +``` + +#### Bridge state + +Once registered, examine the current state of the bridge: + +```bash +$ hue +Philips Hue +Button pressed: false +Timestamp: 2013-09-02T17:44:10 +Network configuration: + IP: 196.168.0.1 + Gateway: 196.168.0.255 + Mask: 255.255.255.0 + DHCP: true + MAC: 00:00:00:00:00:00 + Proxy: none:0 +Applications: + 073f2ed95fcbbef2532c751dd404cc9d : hue-cli +Lights: + 1. Living Overhead - OFF + 2. Living Cabinet - OFF + 3. Living Corner Lamp - OFF +``` + +#### Lights state + +##### Examine or operate on all lights + +```bash +$ hue lights +1. Bedroom - Overhead: ON + Brightness: 254 (100%) + Color: XY=[0.6736, 0.3221], RGB≈[255, 60, 40] +2. Living Room - Cabinet: OFF + Brightness: 142 (56%) + Color: XY=[0.674, 0.322], RGB≈[255, 60, 40] +3. Living Room - Overhead: ON + Brightness: 254 (100%) + Color: Temperature=2710°K (369 mired), RGB≈[255, 166, 87] +``` + +##### Find new lights + +```bash +$ hue lights find +``` + +#### Light state + +Examine the state of a single light: + +```bash +$ hue light 1 +``` + +or just: + +```bash +$ hue 1 +1. Bedroom - Overhead: ON + Brightness: 254 (100%) + Color: XY=[0.6736, 0.3221], RGB≈[255, 60, 40] +``` + +##### Change Light state + +Change a single light state with the following (rather self-explanatory) commands: + +```bash +$ hue 1 on +$ hue 1 off +$ hue 1 flash +$ hue 1 blink +$ hue 1 solid +``` + +##### Brightness + +Brightness can be set with a value (0-255) or percentage (0%-100%): + +```bash +$ hue 1 brightness 200 +$ hue 1 brightness 50% +$ hue 1 +Living Overhead: ON +Brightness: 128 (50%) +Color: Temperature=2012°K (497 mired), RGB≈[255, 136, 13] +``` + +##### Color + +The color command takes 1-3 arguments, each interpreted depending on range and value. + +Set to 6500 Kelvin: + +```bash +$ hue 1 color 6500 +$ hue 1 +Living Overhead: ON +Brightness: 128 (50%) +Color: Temperature=6500°K (153 mired), RGB≈[255, 254, 250] +``` + +Set to 240 mired: + +```bash +$ hue 1 color 240 +$ hue 1 +Living Overhead: ON +Brightness: 128 (50%) +Color: Temperature=4167°K (240 mired), RGB≈[255, 208, 170] +``` + +Set the hue and saturation: + +```bash +$ hue 1 color 30000 255 +$ hue 1 +Living Overhead: ON +Brightness: 128 (50%) +Color: Hue=30000, Saturation=255, RGB≈[0, 255, 190] +``` + +Set the XY: + +```bash +$ hue 1 color 0.25 0.25 +$ hue 1 +Living Overhead: ON +Brightness: 128 (50%) +Color: Hue=30000, Saturation=255, RGB≈[0, 255, 190] +``` + +#### Set all lights + +All the single light state commands can be applied to all lights: + +```bash +$ hue lights brightness 50% +``` + +#### State aliases + +The following state aliases exist and can be used on all or single lights: + +```bash +$ hue 1 relax +$ hue lights energize +$ hue 1 reading +$ hue 1 concentrate +$ hue 1 red +$ hue 1 blue +$ hue 1 green +$ hue 1 white +$ hue 1 pink +``` + +#### State Dump + +Dump the current state of the light, this will display the current state as a JSON hash. +This can then be pasted into your local (`~/.hue-cli/light_alias.yml`) or system (`/etc/hue/light_alias.yml`) alias files. + +```bash +$ hue 1 dump +$ hue lights dump +``` + +Paste into your file like this: + +```yaml +"yellow": {"on":true,"bri":254,"xy":[0.4823,0.4621],"alert":"none","effect":"none"} +``` + +## Ruby Library Usage + +Start by firing up the irb console: + +```bash +$ irb +``` + +```ruby +>> require 'hue' +=> true +``` + +Start by registering your application. Press the button on the bridge and execute: + +```ruby +>> Hue.register_default +=> #<Hue::Bridge:0x8b9d950 @application_id="4aa41fe737808af3559f3d22ca67a0ca", @bridge_uri="http://198.168.1.1/api"> +``` + +This will create two config files in your `~/.hue-cli` directory. +One for the bridges discovered on your network and one for the registered application. + +You can fetch the default application thus: + +```ruby +>> bridge = Hue.application +=> #<Hue::Bridge:0x8b9d950 @application_id="4aa41fe737808af3559f3d22ca67a0ca", @bridge_uri="http://198.168.1.1/api"> +``` + +You can see all of the lights attached to your controller by querying the bridge: + +```ruby +>> bridge.lights +=> {"1"=>{"name"=>"Bedroom Overhead"}, "2"=>{"name"=>"Living Overhead"}, "3"=>{"name"=>"Standing Lamp"}, "4"=>{"name"=>"Living Cabinet"}} +>> bridge.light_names +=> "1. Bedroom Overhead\n2. Living Overhead\n3. Standing Lamp\n4. Living Cabinet" +``` + +If you know the ID number of a particular lamp, you can access it directly: + +```ruby +>> b = Hue::Bulb.new(bridge, 1) +=> #<Hue::Bulb:0x007fe35a3586b8 @bridge=#<Hue::Bridge:0x007fe35a358690 @id="1">> + +# on/off +>> b.on? +=> false + +>> b.on +=> true + +>> b.on? +=> true + +# brightness +>> b.brightness = 128 +=> 128 + +# color +>> b.color = Hue::Colors::ColorTemperature.new(6500) +=> Temperature=6500°K (153 mired) + +>> b.color = Hue::Colors::HueSaturation.new(10_000, 255) +=> Hue=10000, Saturation=255 + +>> b.color = Hue::Colors::XY.new(0.5, 0.5) +=> XY=[0.5, 0.5] + +# blinking +>> b.blinking? +=> false + +>> b.blink + +>> b.blinking? +=> true + +>> b.solid + +>> b.blinking? +=> false +``` + +## Development + +### Building the gem + +```bash +$ rake gem:build +``` + +### Installing locally + +```bash +$ rake gem:install +``` + +### Running tests + +```bash +$ rake spec +``` + +## License + +This project is open source and available under the MIT License. diff --git a/hue/Rakefile b/hue/Rakefile @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rake' + +begin + require 'rspec/core/rake_task' + RSpec::Core::RakeTask.new(:spec) do |t| + t.rspec_opts = ['-fd', '-c'] + t.ruby_opts = ['-Ispec,lib'] + t.pattern = 'spec/**/*_spec.rb' + end +rescue LoadError + desc 'Spec rake task not available' + task :spec do + abort 'Spec rake task is not available. Be sure to install rspec as a gem or plugin' + end +end + +namespace :gem do + desc 'Build the gem' + task :build do + mkdir_p 'pkg' + sh 'gem build hue.gemspec' + sh 'mv *.gem pkg/' + end + + desc 'Publish the gem' + task publish: :build do + gem = Dir['pkg/*.gem'].max_by { |f| File.mtime(f) } + sh "gem push #{gem}" + end + + desc 'Install the gem locally' + task install: :build do + gem = Dir['pkg/*.gem'].max_by { |f| File.mtime(f) } + sh "gem install #{gem}" + end +end + +desc 'Remove generated files' +task :clean do + rm_rf 'pkg' +end + +task default: [:spec] diff --git a/hue/bin/hue b/hue/bin/hue @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'hue' + +Hue::CLI.run(ARGV) diff --git a/hue/hue-0.1.0.gem b/hue/hue-0.1.0.gem Binary files differ. diff --git a/hue/hue.gemspec b/hue/hue.gemspec @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('lib', __dir__) + +Gem::Specification.new do |s| + s.name = 'hue' + s.version = '0.1.0' + s.authors = ['mtmn'] + s.email = ['miro@mtmn.name'] + s.homepage = 'https://codeberg.org/mtmn/tools' + s.summary = 'Ruby library and CLI for controlling Philips Hue lights and bridge.' + s.description = 'Library and command line interface for controlling the Philips Hue system. ' \ + 'Allows registration and invocation of a registered Philips Hue app, with ' \ + 'convenient objects for executing commands on the bridge or individual bulbs.' + s.metadata['rubygems_mfa_required'] = 'true' + + s.files = Dir['lib/**/*', 'bin/*', 'Gemfile', '*.gemspec', 'README.md'].select { |f| File.file?(f) } + s.executables = Dir['bin/*'].map { |f| File.basename(f) } + s.require_paths = ['lib'] + + s.add_dependency('json', '~> 2.18') + s.add_dependency('matrix') + + s.add_development_dependency('rake') + s.add_development_dependency('rspec', '>= 3.0') + s.add_development_dependency('webmock', '>= 3.0') + + s.required_ruby_version = '>= 3.0' +end diff --git a/hue/lib/config/aliases.yml b/hue/lib/config/aliases.yml @@ -0,0 +1,12 @@ +"relax": {"on":true,"bri":144,"xy":[0.5016,0.4151],"alert":"none","effect":"none"} +"concentrate": {"on":true,"bri":254,"xy":[0.3681,0.3718],"alert":"none","effect":"none"} +"reading": {"on":true,"bri":200,"xy":[0.4374,0.4045],"alert":"none","effect":"none"} +"dimmed": {"on":true,"bri":46, "xy":[0.5614,0.4156],"alert":"none","effect":"none"} +"energize": {"on":true,"bri":254,"xy":[0.3127,0.3290],"alert":"none","effect":"none"} + +"red": {"on":true,"bri":254,"xy":[0.6915,0.3083],"alert":"none","effect":"none"} +"green": {"on":true,"bri":254,"xy":[0.1700,0.7000],"alert":"none","effect":"none"} +"blue": {"on":true,"bri":254,"xy":[0.1532,0.0475],"alert":"none","effect":"none"} +"white": {"on":true,"bri":254,"xy":[0.3127,0.3290],"alert":"none","effect":"none"} +"pink": {"on":true,"bri":254,"xy":[0.3897,0.1714],"alert":"none","effect":"none"} +"yellow": {"on":true,"bri":254,"xy":[0.4969,0.4659],"alert":"none","effect":"none"} diff --git a/hue/lib/hue.rb b/hue/lib/hue.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' +require 'securerandom' +require 'logger' +require 'fileutils' +require 'socket' +require 'timeout' + +module Hue + DEVICE_TYPE = 'hue-cli' + DEFAULT_UDP_TIMEOUT = 5 + ERROR_DEFAULT_EXISTS = 'Default application already registered.' + ERROR_NO_BRIDGE_FOUND = 'No bridge found.' + + def self.device_type + DEVICE_TYPE + end + + def self.one_time_uuid + SecureRandom.hex(16) + end + + def self.register_default + raise Hue::Error, ERROR_DEFAULT_EXISTS if begin + Hue::Config::Application.default + rescue StandardError + nil + end + + bridge_config = register_bridges.values.first # Assuming one bridge for now + puts 'Registering new app...' + instance = Hue::Bridge.register(bridge_config.uri) + app_config = Hue::Config::Application.new(bridge_config.id, instance.application_id) + app_config.write + instance + end + + def self.application + application_config = Hue::Config::Application.default + bridge_config = Hue::Config::Bridge.find(application_config.bridge_id) + bridge_config ||= register_bridges[application_config.bridge_id] + + raise Error, "Unable to find bridge: #{application_config.bridge_id}" if bridge_config.nil? + + Hue::Bridge.new(application_config.id, bridge_config.uri) + end + + def self.remove_default + instance = application + instance.unregister + Hue::Config::Application.default.delete + true + end + + def self.discover + bridges = {} + udp_discover(bridges) + nupnp_discover(bridges) + bridges + end + + def self.udp_discover(bridges) + Hue.logger.info('Bridge UDP Discovery') + payload = <<~PAYLOAD + M-SEARCH * HTTP/1.1 + ST: ssdp:all + MX: 10 + MAN: ssdp:discover + HOST: 239.255.255.250:1900 + PAYLOAD + broadcast_address = '239.255.255.250' + port_number = 1900 + + socket = UDPSocket.new(Socket::AF_INET) + socket.send(payload, 0, broadcast_address, port_number) + + Timeout.timeout(DEFAULT_UDP_TIMEOUT, Hue::Error) do + loop do + message, (_, port, hostname, ip_add) = socket.recvfrom(1024) + if message =~ /IpBridge/ && /LOCATION: (.*)$/.match(message) + if (uuid_match = /uuid:(.{36})/.match(message)) + # Assume this is Philips Hue for now. + uuid = uuid_match.captures.first + logger.info("Found bridge (#{hostname}:#{port}) with uuid: #{uuid}") if bridges[uuid].nil? + bridges[uuid] = "http://#{ip_add}/api" + end + else + logger.debug("Found #{hostname}:#{port}: #{message}") + end + end + end + rescue Hue::Error => e + Hue.logger.warn(e) + logger.info('UDPSocket timed out.') + end + + def self.nupnp_discover(bridges) + return unless bridges.empty? + + Hue.logger.info('Bridge NUPNP Discovery') + response = Net::HTTP.get_response(URI('https://www.meethue.com/api/nupnp')) + json = begin + JSON.parse(response.body) + rescue StandardError + nil + end + return if json.nil? + + json.each do |bridge| + uuid = bridge['id'] + ip_add = bridge['internalipaddress'] + bridges[uuid] = "http://#{ip_add}/api" unless uuid.nil? || ip_add.nil? + end + end + + def self.register_bridges + bridges = discover + raise Error, ERROR_NO_BRIDGE_FOUND if bridges.empty? + + bridges.each_with_object({}) do |(id, ip), hash| + config = Hue::Config::Bridge.new(id, ip) + config.write + hash[id] = config + end + end + + class Error < StandardError + attr_accessor :original_error + + def initialize(message, original_error = nil) + super(message) + @original_error = original_error + end + + def to_s + if @original_error.nil? + super + else + "#{super}\nCause: #{@original_error}" + end + end + end + + module API + class Error < ::Hue::Error + def initialize(api_error) + @type = api_error['type'] + @address = api_error['address'] + super(api_error['description']) + end + end + end + + def self.logger + @logger ||= begin + log_dir_path = File.join(ENV['HOME'], '.hue') + begin + FileUtils.mkdir_p(log_dir_path) + rescue Errno::EACCES + log_dir_path = File.join(ENV['HOME'], ".#{device_type}") + FileUtils.mkdir_p(log_dir_path) + end + + log_file_path = File.join(log_dir_path, 'hue.log') + log_file = File.new(log_file_path, File::WRONLY | File::APPEND | File::CREAT) + logger = Logger.new(log_file) + logger.level = Logger::INFO + logger + end + end + + def self.percent_to_unit_interval(value) + if (percent = /(\d+)%/.match(value.to_s)) + percent.captures.first.to_i / 100.0 + end + end +end + +# Core library components +require_relative 'hue/config/abstract' +require_relative 'hue/config/application' +require_relative 'hue/config/bridge' +require_relative 'hue/bridge' +require_relative 'hue/colors' +require_relative 'hue/bulb' + +# CLI components (utilities must come before extensions) +require_relative 'hue/utilities/indentation' +require_relative 'hue/cli' +require_relative 'hue/cli/command' +require_relative 'hue/cli/processors/light_state_alias' +require_relative 'hue/cli/commands/register' +require_relative 'hue/cli/commands/delete' +require_relative 'hue/cli/commands/light' +require_relative 'hue/cli/commands/lights' +require_relative 'hue/extensions/hue' +require_relative 'hue/extensions/bridge' +require_relative 'hue/extensions/bulb' diff --git a/hue/lib/hue/animations/candle.rb b/hue/lib/hue/animations/candle.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Hue + module Animations + module Candle + def candle(repeat = 15) + # 0-65536 for hue, 182 per deg. Ideal 30-60 deg (5460-10920) + stash! + on if off? + + repeat.times do + hue = ((rand * 3460) + 5460).to_i + sat = rand(170..233) + bri = rand(16..47) + + delay = (rand * 0.35) + (@delay ||= 0) + update(hue: hue, sat: sat, bri: bri, transitiontime: (delay * 10).to_i) + sleep delay + end + restore! + end + + private + + def options_with_colorstate + options.merge case state['colormode'] + when 'ct' + { 'ct' => state['ct'] } + when 'xy' + { 'xy' => state['xy'] } + when 'hs' + { 'hue' => state['hue'], 'sat' => state['sat'] } + end.merge('on' => state['on'], 'bri' => state['bri']) + end + + def restore! + return unless stash + + update(@stash) + unstash! + end + + def stash! + @stash ||= options_with_colorstate + end + + def unstash! + @stash = nil + end + end + end +end diff --git a/hue/lib/hue/animations/sunrise.rb b/hue/lib/hue/animations/sunrise.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Hue + module Animations + module Sunrise + SUN_STEPS = [1.5, 2, 3, 1, 4, 2.5].freeze + SUN_TIMES = [3, 3, 3, 1, 2, 1].freeze + + # Experimental Sunrise/Sunset action + # this will transition from off and warm light to on and daytime light + # in a curve that mimics the actual sunrise. + + def perform_sunrise(total_time_in_minutes = 18) + multiplier = sunrise_multiplier total_time_in_minutes + steps = sunrise_steps(multiplier) + if on? + puts "ON! #{steps[0][:bri]} :: #{brightness} :: #{brightness > steps[0][:bri]}" + steps.shift while brightness >= steps[0][:bri] + end + steps.each_with_index do |step, _i| + update step.merge(on: true) + sleep(step[:transitiontime] / 10.0) + end + end + + def perform_sunset(total_time_in_minutes = 18) + multiplier = sunrise_multiplier total_time_in_minutes + steps = sunset_steps(multiplier) + if on? + puts "ON! #{steps[0][:bri]} :: #{brightness} :: #{brightness > steps[0][:bri]}" + steps.shift while brightness <= steps[0][:bri] + end + steps.each_with_index do |step, _i| + update step.merge(on: true) + sleep(step[:transitiontime] / 10.0) + end + off + end + + private + + def sunrise_multiplier(total_time_in_minutes) + # total_time / 18 steps == time_per_step + # the multiplier should be 600 * time per step + minutes_per_step = total_time_in_minutes / 18.0 + (minutes_per_step * 60 * 10).to_i + end + + def sunrise_brightness + sun_bri_unit = 10 + SUN_STEPS.inject([0]) { |all, i| all << ((i * sun_bri_unit) + all[-1]).to_i } << 255 + end + + def sunrise_temps + sun_temp_unit = 16 + SUN_STEPS.inject([500]) { |all, i| all << (all[-1] - (i * sun_temp_unit)).to_i } << 200 + end + + def sunrise_times + [0, SUN_TIMES, 5].flatten + end + + def sunset_times + [0, 5, SUN_TIMES.reverse].flatten + end + + def sunrise_steps(multiplier = 600) + bri_steps = sunrise_brightness + tmp_steps = sunrise_temps + + steps = [] + sunrise_times.each_with_index do |t, i| + steps << { bri: bri_steps[i], ct: tmp_steps[i], transitiontime: (t * multiplier) } + end + steps + end + + def sunset_steps(multiplier = 600) + bri_steps = sunrise_brightness.reverse + tmp_steps = sunrise_temps.reverse + + steps = [] + sunset_times.each_with_index do |t, i| + steps << { bri: bri_steps[i], ct: tmp_steps[i], transitiontime: (t * multiplier) } + end + steps + end + end + end +end diff --git a/hue/lib/hue/bridge.rb b/hue/lib/hue/bridge.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'socket' +require 'timeout' +require 'logger' + +module Hue + class Bridge + attr_reader :application_id, :bridge_uri + + def initialize(application_id, bridge_uri) + @application_id = application_id + @bridge_uri = bridge_uri + end + + def status + index(uri) + end + + def add_lights + create(uri('lights')) + end + + def lights + index(uri('lights')) + end + + def light_names + lights.map { |k, v| "#{k}. #{v['name']}" }.join("\n") + end + + def config + index(uri('config')) + end + + def schedules + index(uri('schedules')) + end + + def bulbs + @bulbs ||= lights.keys.map { |b| Bulb.new(self, b) } + end + + # def remove_schedule(schedule_id) + # delete uri('schedules', schedule_id) + # puts "Removed schedule #{schedule_id}" + # end + + # def remove_all_schedules + # ids = schedules.keys.map(&:to_i).sort.reverse + # puts "Removing #{ids.size} schedule#{'s' if ids.size != 1}..." + # ids.each{|x| remove_schedule x} + # end + + def self.register(bridge_uri) + instance = new('', bridge_uri) + instance.register + instance + end + + def register + response = create(URI.parse(bridge_uri), { 'devicetype' => "#{Hue.device_type}@#{Socket.gethostname}" }) + return unless response.first.key?('success') + + @application_id = response.first['success']['username'] + end + + def unregister + delete(uri('config', 'whitelist', application_id)) + end + + def get_light(id) + index(uri('lights', id)) + end + + def set_light(id, property) + update(uri('lights', id), property) + end + + def set_light_state(id, state) + update(uri('lights', id, 'state'), state) + end + + private + + def uri(*args) + URI.parse([bridge_uri, application_id, args].flatten.reject { |x| x.to_s.strip == '' }.join('/')) + end + + def index(url) + receive(Net::HTTP::Get, url) + end + + def update(url, settings = {}) + receive(Net::HTTP::Put, url, settings.to_json) + end + + def delete(url, settings = {}) + receive(Net::HTTP::Delete, url, settings.to_json) + end + + def create(url, settings = {}) + receive(Net::HTTP::Post, url, settings.to_json) + end + + def receive(request_class, url, payload = nil) + request = request_class.new(url.request_uri, { 'Content-Type' => 'application/json' }) + request.body = payload if payload + Hue.logger.info("Sending #{payload} to #{url}") + response = nil + begin + http = Net::HTTP.new(url.host, url.port) + http.open_timeout = 3 # Quick timeout on connection fail. + http.read_timeout = 8 # Slower timeout on read fail, but way faster than the default. + response = http.start { |http| http.request(request) } + rescue StandardError => e + Hue.logger.error(e.message) + raise Hue::Error.new('Problem reaching bridge.', e) + end + + if response && response.code.to_s != '200' + Hue.logger.info("Error with response #{response.code} #{response.message}") + raise Hue::Error, "Unexpected response: #{response.code}, #{response.message}" + else + json = JSON.parse(response.body) + Hue.logger.info("Response #{response.code} #{response.message}: #{json}") + if json.is_a?(Array) && (error = json.first['error']) + raise Hue::API::Error, error + else + json + end + end + end + end +end diff --git a/hue/lib/hue/bulb.rb b/hue/lib/hue/bulb.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require_relative 'animations/candle' +require_relative 'animations/sunrise' + +module Hue + class Bulb + BRIGHTNESS_MAX = 255 + NONE = 'none' + COLOR_LOOP = 'colorloop' + + include Animations::Candle + include Animations::Sunrise + + attr_reader :id, :bridge + attr_accessor :options + + def initialize(bridge, light_id, options = {}) + @bridge = bridge + @id = light_id + @options = options + end + + def refresh! + @status = bridge.get_light(id) + end + + def info + status.select do |_k, value| + value.is_a?(String) + end + end + + def state + status['state'] + end + + # Free for all, no checking. + def state=(value) + update_state(value) + end + + def [](item) + state[item.to_s] + end + + def name + status['name'] + end + + def name=(new_name) + update(name: new_name) + end + + def on? + self[:on] + end + + def off? + !on? + end + + def on + update_state(on: true) + on? + end + + def off + update_state(on: false) + off? + end + + def brightness + self[:bri] + end + + alias bri brightness + + def brightness=(bri) + if (scale = Hue.percent_to_unit_interval(bri)) + update_state(bri: (scale * BRIGHTNESS_MAX).round) + else + update_state(bri: bri.to_i) + end + brightness + end + + alias bri= brightness= + + def brightness_in_unit_interval + brightness / BRIGHTNESS_MAX.to_f + end + + def brightness_percent + (brightness_in_unit_interval * 100).round + end + + def color_mode + self[:colormode] + end + + alias colormode color_mode + + def color + set_color + end + + def color=(col) + update_state(col.to_hash) + set_color + end + + def effect + self['effect'] + end + + def effect=(value) + update_state(effect: value.to_s) + end + + def effect? + effect != NONE + end + + def color_loop + self.effect = COLOR_LOOP + end + alias colorloop color_loop + + def color_loop? + effect == COLOR_LOOP + end + + def clear_effect + self.effect = NONE + end + + def alert + self['alert'] + end + + def alert=(value) + update_state(alert: value.to_s) + end + + def blinking? + !solid? + end + + def solid? + alert == NONE + end + + def blink + self.alert = 'lselect' + end + + def solid + self.alert = NONE + end + + def flash + self.alert = 'select' + # immediately update to expected state + status['state']['alert'] = NONE + end + + # transition time in seconds + def transition_time + (options[:transitiontime] || 0).to_f / 10 + end + + def transition_time=(time) + options[:transitiontime] = (time.to_f * 10).to_i + end + + private + + def status + @status || refresh! + end + + def set_color + @color = Colors.parse_state(state) + end + + def update_state(settings = {}) + return unless bridge.set_light_state(id, options.merge(settings)) + + set_status(settings, 'state') + end + + def update(settings = {}) + return unless bridge.set_light(id, settings) + + set_status(settings) + end + + def set_status(settings, start_key = nil) + return unless @status + + status = start_key ? @status[start_key] : @status + settings.each do |key, value| + status[key.to_s] = value + end + end + end +end diff --git a/hue/lib/hue/cli.rb b/hue/lib/hue/cli.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'find' + +module Hue + module CLI + LOCATION = File.expand_path(File.join(__dir__, '..', '..')) + + class Error < StandardError; end + + ERROR_BRIDGE_CONNECTION_PROBLEM = /execution expired|No route to host/ + + class << self + attr_accessor :retry_count + + def bridge + Hue.application + end + + def run(args = []) + @retry_count ||= 0 + commands = commands_in_object_space + + if args.empty? + print_default + else + execute_command(args, commands) + end + rescue Hue::Error => e + handle_bridge_error(e, args) + rescue Hue::CLI::Error => e + puts e.message + end + + private + + def execute_command(args, commands) + first_arg = args.shift + command = commands[first_arg.to_sym] + + if command + command.new.execute(*args) + elsif first_arg.to_i.positive? + Commands::Light.new.execute(first_arg, *args) + else + raise Error, "Unknown command: #{first_arg}" + end + end + + def handle_bridge_error(error, original_args) + return puts error.message unless ERROR_BRIDGE_CONNECTION_PROBLEM.match?(error.message) + + @retry_count += 1 + + if @retry_count > 2 + puts "Giving up contacting bridge after #{@retry_count} retries." + bridge.print_config + @retry_count = 0 + return + end + + Hue.logger.warn('Error contacting bridge, verifying IP and trying again.') + Hue.register_bridges + + # Retry by calling run again with original args + run(original_args.dup) + end + + def print_default + Command.new.execute + rescue Hue::Config::NotFound + handle_not_registered + end + + def handle_not_registered + puts 'Application not registered.' + bridges = Hue.register_bridges + + if bridges + print_bridges(bridges) + puts + puts "Please type 'hue register' to register your application with the bridge." + else + puts 'No bridges found on the local network.' + end + end + + def print_bridges(bridges) + puts 'Found bridges:' + bridges.each do |key, config| + puts " #{config.uri} (uuid: #{key})" + end + end + + def commands_in_object_space + require_commands + ObjectSpace.each_object(Class).with_object({}) do |klass, commands| + next unless klass < Hue::CLI::Command + + command_name = klass.name.split('::').last.downcase.to_sym + commands[command_name] = klass + end + end + + def require_commands + commands_dir = File.join(__dir__, 'cli', 'commands') + Find.find(commands_dir) do |path| + require path if File.file?(path) && path.end_with?('.rb') + end + end + end + end +end diff --git a/hue/lib/hue/cli/command.rb b/hue/lib/hue/cli/command.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Hue + module CLI + class Command + attr_reader :bridge + + def initialize + @bridge = Hue::CLI.bridge + @methods = available_methods + end + + def execute(*args) + args.empty? ? bridge.print_state : send_method(*args) + end + + private + + def available?(method_name) + @methods[method_name.to_sym] + end + + def available!(method_name) + return true if available?(method_name) + + raise Error, "Action '#{method_name}' is not available for operation '#{class_name}'" + end + + def class_name + self.class.name.split('::').last.downcase + end + + def available_methods + (self.class.instance_methods - Command.instance_methods) + .to_h { |method_name| [method_name, true] } + end + + def send_method(*args) + method = args.shift.to_sym + send(method, *args) if available!(method) + end + end + end +end diff --git a/hue/lib/hue/cli/commands/delete.rb b/hue/lib/hue/cli/commands/delete.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Hue + module CLI + module Commands + class Delete < Hue::CLI::Command + def execute(*args) + app_id = args.shift + + if app_id.nil? + bridge.unregister + else + Bridge.new(app_id, bridge.bridge_uri).unregister + end + end + + protected + + def default(*_args) + Hue.register_default + end + end + end + end +end diff --git a/hue/lib/hue/cli/commands/light.rb b/hue/lib/hue/cli/commands/light.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Hue + module CLI + module Commands + class Light < Hue::CLI::Command + attr_reader :bulb + + def execute(*args) + arg = args.shift + light_number = arg.to_i + + raise Hue::CLI::Error, "Not a valid light number: #{arg}" unless light_number.positive? + + @bulb = Hue::Bulb.new(bridge, light_number) + + if args.empty? + bulb.print_state + elsif available?(args.first) + super + else + apply_alias(args.join(' ')) + end + end + + protected + + def on(*_args) + bulb.on + end + + def off(*_args) + bulb.off + end + + def brightness(*args) + bulb.brightness = args.first + end + + def flash(*_args) + bulb.flash + end + + def blink(*_args) + bulb.blink + end + + def solid(*_args) + bulb.solid + end + + def color(*args) + bulb.color = Colors.parse(*args) + end + + def name(*args) + bulb.name = args.join(' ') + end + + def action(*args) + bulb.effect = args.join + end + + def effect(*args) + bulb.effect = args.join + end + + def colorloop(*_args) + bulb.colorloop + end + + def clear(*args) + case args.first + when 'effect' + bulb.clear_effect + when 'action' + bulb.solid + else + bulb.clear_effect + bulb.solid + end + end + + def dump(*_args) + puts bulb.state.to_json + end + + private + + def apply_alias(alias_name) + state_alias = Processors::LightStateAlias.new(alias_name) + bulb.state = state_alias.state + end + end + end + end +end diff --git a/hue/lib/hue/cli/commands/lights.rb b/hue/lib/hue/cli/commands/lights.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Hue + module CLI + module Commands + class Lights < Hue::CLI::Command + def execute(*args) + if args.empty? + bridge.print_bulbs + elsif available?(args.first) + send_method(*args) + else + apply_to_all_lights(*args) + end + end + + protected + + def find + bridge.add_lights + end + + private + + def apply_to_all_lights(*args) + bridge.lights.each_key do |number| + Light.new.execute(number, *args) + end + end + end + end + end +end diff --git a/hue/lib/hue/cli/commands/register.rb b/hue/lib/hue/cli/commands/register.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Hue + module CLI + module Commands + class Register < Hue::CLI::Command + def initialize + super + @methods = available_methods + end + + def execute(*args) + args.empty? ? default : super + end + + protected + + def default(*_args) + print_button_instructions + Hue.register_default + end + + private + + def print_button_instructions + puts 'Please press the button on your bridge.' + sleep 1 + puts 'Once done, press Enter to continue with the registration.' + $stdin.gets + end + end + end + end +end diff --git a/hue/lib/hue/cli/processors/light_state_alias.rb b/hue/lib/hue/cli/processors/light_state_alias.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'yaml' + +module Hue + module CLI + module Processors + class LightStateAlias + ALIAS_FILE_NAME = 'aliases.yml' + ALIAS_FILE_PATHS = [ + File.join(Hue::CLI::LOCATION, 'lib', 'config'), # Default (bundled with gem) + '/etc/hue/', # System + File.join(ENV['HOME'], '.hue') # User + ].freeze + + @alias_map = {} + + class << self + attr_reader :alias_map + + def read_alias_files + ALIAS_FILE_PATHS.each do |path| + file_path = File.expand_path(File.join(path, ALIAS_FILE_NAME)) + load_aliases_from(file_path) + end + end + + private + + def load_aliases_from(file_path) + yaml = YAML.load_file(file_path, permitted_classes: [Symbol]) + yaml.each do |name, state| + @alias_map[name.to_sym] = state + end + rescue Errno::ENOENT, Psych::SyntaxError + # File doesn't exist or is invalid, skip it + end + end + + read_alias_files + + attr_reader :name, :state + + def initialize(name) + @name = name + @state = self.class.alias_map[name.to_sym] + raise Error, "Unknown light state: #{name}" if @state.nil? + end + end + end + end +end diff --git a/hue/lib/hue/colors.rb b/hue/lib/hue/colors.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative 'colors/color' +require_relative 'colors/hue_saturation' +require_relative 'colors/color_temperature' +require_relative 'colors/xy' +require_relative 'colors/rgb' + +module Hue + module Colors + def self.parse(*args) + case args.size + when 1 + Colors::ColorTemperature.new(args.first) + when 2 + a = args.first.to_f + _ = args.last.to_f + if a > 1.0 + Colors::HueSaturation.new(args.first, args.last) + else + Colors::XY.new(*args) + end + when 3 + Colors::RGB.new(*args) + else + raise Error, "Unable to parse to color: #{args.inspect}" + end + end + + def self.parse_state(state) + case state['colormode'] + when 'ct' + Colors::ColorTemperature.new(state['ct']) + when 'xy' + Colors::XY.new(*state['xy']) + when 'hs' + Colors::HueSaturation.new(state['hue'], state['sat']) + else + raise Error, "Unknown or missing state: #{state.inspect}" + end + end + end +end diff --git a/hue/lib/hue/colors/color.rb b/hue/lib/hue/colors/color.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Hue + module Colors + class Color + ERROR_METHOD_NOT_IMPLEMENTED = 'method-not-implemented' + + def self.ranged(min, val, max) + [[min, val].max, max].min + end + + def to_hash + raise ERROR_METHOD_NOT_IMPLEMENTED + end + + def to_s + raise ERROR_METHOD_NOT_IMPLEMENTED + end + + def to_rgb + raise ERROR_METHOD_NOT_IMPLEMENTED + end + + protected + + def ranged(min, val, max) + # For convinence and polymorphism + self.class.ranged(min, val, max) + end + end + end +end diff --git a/hue/lib/hue/colors/color_temperature.rb b/hue/lib/hue/colors/color_temperature.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Hue + module Colors + class ColorTemperature < Color + MEGA = 1e6 + KELVIN_MIN = 2000 + KELVIN_MAX = 6500 + MIRED_MIN = 153 + MIRED_MAX = 500 + + def initialize(temperature) + super() + if (scale = Hue.percent_to_unit_interval(temperature)) + self.mired = unit_to_mired_interval(scale) + else + # Assume an integer value + temperature = temperature.to_i + if temperature >= KELVIN_MIN + self.kelvin = temperature + else + self.mired = temperature + end + end + end + + def mired + @mired.floor + end + + def mired=(temp) + @mired = ranged(MIRED_MIN, temp.to_f, MIRED_MAX) + end + + def kelvin + ranged(KELVIN_MIN, MEGA / @mired, KELVIN_MAX).round + end + + def kelvin=(temp) + self.mired = (MEGA / ranged(KELVIN_MIN, temp.to_f, KELVIN_MAX)) + end + + def to_hash + { ct: mired } + end + + def to_s + "Temperature=#{kelvin.to_i}°K (#{mired} mired)" + end + + def to_rgb + # using method described at + # http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/ + temp = kelvin / 100 + + red = temp <= 66 ? 255 : 329.698727446 * ((temp - 60)**-0.1332047592) + + green = if temp <= 66 + 99.4708025861 * Math.log(temp) - 161.1195681661 + else + 288.1221695283 * ((temp - 60)**-0.0755148492) + end + + blue = if temp >= 66 + 255 + elsif temp <= 19 + 0 + else + 138.5177312231 * Math.log(temp - 10) - 305.0447927307 + end + + RGB.new(red, green, blue) + end + + private + + def unit_to_mired_interval(unit_interval) + unit_interval * (MIRED_MAX - MIRED_MIN) + MIRED_MIN + end + end + end +end diff --git a/hue/lib/hue/colors/hue_saturation.rb b/hue/lib/hue/colors/hue_saturation.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Hue + module Colors + class HueSaturation < Color + HUE_MIN = 0 + HUE_MAX = 65_536.0 + HUE_DEGREES = 360 + HUE_SCALE = HUE_MAX / HUE_DEGREES + SATURATION_MIN = 0 + SATURATION_MAX = 255 + + attr_reader :hue, :saturation + alias sat saturation + + def initialize(hue, saturation) + super() + self.hue = hue + self.saturation = saturation + end + + def hue=(value) + @hue = if (scale = Hue.percent_to_unit_interval(value)) + unit_to_hue_interval(scale) + else + ranged(HUE_MIN, value.to_i, HUE_MAX) + end + end + + def hue_in_degrees + hue.to_f / HUE_SCALE + end + + def hue_in_unit_interval + hue_in_degrees / HUE_DEGREES + end + + def saturation=(value) + @saturation = if (scale = Hue.percent_to_unit_interval(value)) + unit_to_saturation_interval(scale) + else + ranged(SATURATION_MIN, value.to_i, SATURATION_MAX) + end + end + alias sat= saturation= + + def saturation_in_unit_interval + saturation / SATURATION_MAX.to_f + end + alias sat_in_unit_interval saturation_in_unit_interval + + def to_s + "Hue=#{hue}, Saturation=#{saturation}" + end + + def to_hash + { hue: hue, sat: saturation } + end + + def to_rgb(brightness_in_unit_interval = 1.0) + h = hue_in_unit_interval + s = saturation_in_unit_interval + v = brightness_in_unit_interval + if s.zero? # monochromatic + red = green = blue = v + else + + v = 1.0 # We are setting the value to 1. Don't count brightness here + i = (h * 6).floor + f = h * 6 - i + p = v * (1 - s) + q = v * (1 - f * s) + t = v * (1 - (1 - f) * s) + + case i % 6 + when 0 + red = v + green = t + blue = p + when 1 + red = q + green = v + blue = p + when 2 + red = p + green = v + blue = t + when 3 + red = p + green = q + blue = v + when 4 + red = t + green = p + blue = v + when 5 + red = v + green = p + blue = q + end + end + + RGB.new(red * RGB::MAX, green * RGB::MAX, blue * RGB::MAX) + end + + private + + def unit_to_hue_interval(value) + (value * (HUE_MAX - HUE_MIN) + HUE_MIN).round + end + + def unit_to_saturation_interval(value) + (value * (SATURATION_MAX - SATURATION_MIN) + SATURATION_MIN).round + end + end + end +end diff --git a/hue/lib/hue/colors/rgb.rb b/hue/lib/hue/colors/rgb.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Hue + module Colors + class RGB < Color + MIN = 0 + MAX = 255 + + def self.ranged(value) + super(MIN, value, MAX) + end + + attr_reader :red, :green, :blue + + def initialize(*rgb) + super() + red, green, blue = rgb + self.red = red + self.green = green + self.blue = blue + end + + def red=(value) + @red = parse(value) + end + + def green=(value) + @green = parse(value) + end + + def blue=(value) + @blue = parse(value) + end + + def to_hash + max = MAX.to_f + red = self.red / max + green = self.green / max + blue = self.red / max + + max = [red, green, blue].max + min = [red, green, blue].min + _ = 0 + _ = ((max + min) / 2 * 255) + + d = max - min + s = max.zero? ? 0 : (d / max * 255) + + h = case max + when min + 0 # monochromatic + when red + (green - blue) / d + (green < blue ? 6 : 0) + when green + (blue - red) / d + 2 + when blue + (red - green) / d + 4 + end * 60 + + h = (h * HueSaturation::HUE_SCALE).to_i + { hue: h, sat: s.to_i, bri: Bulb::BRIGHTNESS_MAX } + end + + def to_s + "RGB≈#{rgb}" + end + + def to_rgb + self + end + + def ==(other) + other.is_a?(RGB) && + %i[red green blue].all? { |m| send(m) == other.send(m) } + end + + protected + + def ranged(value) + self.class.ranged(value.to_i).round + end + + private + + def rgb + [red, green, blue] + end + + def unit_to_rgb_scale(value) + (value * (MAX - MIN) + MIN).round + end + + def parse(value) + if (scale = Hue.percent_to_unit_interval(value)) + unit_to_rgb_scale(scale) + else + ranged(value) + end + end + end + end +end diff --git a/hue/lib/hue/colors/xy.rb b/hue/lib/hue/colors/xy.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'matrix' +module Hue + module Colors + class XY < Color + MIN = 0.0 + MAX = 1.0 + RGB_MATRIX = Matrix[ + [3.233358361244897, -1.5262682428425947, 0.27916711262124544], + [-0.8268442148395835, 2.466767560486707, 0.3323241608108406], + [0.12942207487871885, 0.19839858329512317, 2.0280912276039635] + ] + + attr_reader :x, :y + + def initialize(*xy) + super() + self.x = xy.first + self.y = xy.last + end + + def x=(value) + @x = ranged(value) + end + + def y=(value) + @y = ranged(value) + end + + def to_hash + { xy: xy } + end + + def to_s + "XY=#{xy}" + end + + def to_rgb + z = 1 - x - y + xyz = [x, y, z] + values = (RGB_MATRIX * Matrix[xyz].transpose).to_a.flatten.map do |x| + RGB.ranged(x * RGB::MAX).to_i + end + + RGB.new(*values) + end + + protected + + def ranged(val) + super(MIN, val.to_f, MAX) + end + + private + + def xy + [x, y] + end + end + end +end diff --git a/hue/lib/hue/config/abstract.rb b/hue/lib/hue/config/abstract.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'yaml' +require 'fileutils' + +module Hue + module Config + class NotFound < Hue::Error; end + + class Abstract + attr_reader :path, :name + + def initialize(name, path) + @path = path + @name = name + self.class.setup_file_path(self.path) + end + + def write(overwrite_existing_key: false) + yaml = begin + YAML.load_file(path) + rescue StandardError + nil + end + yaml ||= {} + + if yaml.key?(name) && !overwrite_existing_key + raise "Key named '#{name}' already exists in config file '#{path}'.\nPlease remove it before creating a new one with the same name." + end + + add_self_to_yaml(yaml) + dump_yaml(yaml) + end + + def delete + yaml = begin + YAML.load_file(path) + rescue StandardError + Hash::New + end + + yaml.delete(name) if yaml.key?(name) + + dump_yaml(yaml) + end + + def ==(other) + lhs = self + + lhs.class == other.class && lhs.name == other.name + end + + private + + def add_self_to_yaml(yaml) + yaml[name] = {} + end + + def dump_yaml(yaml) + File.open(path, 'w+') do |out| + YAML.dump(yaml, out) + end + end + + class << self + def setup_file_path(path) + dir = File.dirname(path) + FileUtils.mkdir_p(dir) unless Dir.exist?(dir) + end + + def read_file(config_file) + YAML.load_file(config_file) + rescue StandardError => e + raise Error.new('Failed to read configuration file', e) + end + end + end + end +end diff --git a/hue/lib/hue/config/application.rb b/hue/lib/hue/config/application.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Hue + module Config + class Application < Abstract + STRING_BRIDGE_ID = 'bridge_id' + STRING_DEFAULT = 'default' + STRING_ID = 'id' + + def self.hue_config_dir + File.join(ENV['HOME'], '.hue') + end + + def self.file_path + File.join(hue_config_dir, 'applications.yml') + end + + def self.default + named(STRING_DEFAULT) + end + + def self.named(name) + begin + yaml = read_file(file_path) + rescue StandardError => e + Hue.logger.error(e) + end + + if yaml && (named_yaml = yaml[name]) + new(named_yaml[STRING_BRIDGE_ID], named_yaml[STRING_ID], name) + else + raise NotFound, "Config named '#{name}' not found." + end + end + + attr_reader :bridge_id, :id, :name + + def initialize(bridge_id, id, name = STRING_DEFAULT, path = self.class.file_path) + super(name, path) + @bridge_id = bridge_id + @id = id + end + + def ==(other) + super(other) && + bridge_id == other.bridge_id && + id == other.id + end + + private + + def add_self_to_yaml(yaml) + key = name.dup.force_encoding('ASCII') # Avoid binary encoded YAML + bridge = bridge_id.dup.force_encoding('ASCII') + yaml[key] = { + STRING_ID => id.force_encoding('ASCII'), + STRING_BRIDGE_ID => bridge + } + end + end + end +end diff --git a/hue/lib/hue/config/bridge.rb b/hue/lib/hue/config/bridge.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Hue + module Config + class Bridge < Abstract + def self.hue_config_dir + File.join(ENV['HOME'], '.hue') + end + + def self.file_path + File.join(hue_config_dir, 'bridges.yml') + end + + def self.find(id) + yaml = read_file(file_path) + entry = yaml.select { |k, _v| k.to_s == id.to_s } + if entry.empty? + nil + else + new(id, entry[id]['uri']) + end + end + + attr_accessor :uri + + def initialize(id, uri, path = self.class.file_path) + super(id, path) + @uri = uri + end + + def id + name + end + + def write(overwrite_existing_key: true) + super(overwrite_existing_key: overwrite_existing_key) + end + + private + + def add_self_to_yaml(yaml) + key = id.dup.force_encoding('ASCII') + yaml[key] = { 'uri' => uri.force_encoding('ASCII') } + end + end + end +end diff --git a/hue/lib/hue/extensions/bridge.rb b/hue/lib/hue/extensions/bridge.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Hue + class Bridge + include Indentation + + def print_config + puts 'Config:' + indent do + puts "Application id: #{application_id}" + puts "Bridge URI: #{bridge_uri}" + end + end + + def print_state + state = status + config = state['config'] + + print_basic_info(config) + print_network_config(config) + print_applications(config['whitelist']) + print_lights(state['lights']) + end + + def print_bulbs + bulbs.each(&:print_state) + end + + private + + def print_basic_info(config) + puts config['name'] + puts "Button pressed: #{config['linkbutton']}" + puts "Timestamp: #{config['UTC']}" + end + + def print_network_config(config) + puts 'Network configuration:' + indent do + puts "IP: #{config['ipaddress']}" + puts "Gateway: #{config['gateway']}" + puts "Mask: #{config['netmask']}" + puts "DHCP: #{config['dhcp']}" + puts "MAC: #{config['mac']}" + print_proxy_info(config) if config['proxyaddress']&.strip&.empty? == false + end + end + + def print_proxy_info(config) + puts "Proxy: #{config['proxyaddress']}:#{config['proxyport']}" + end + + def print_applications(whitelist) + puts 'Applications:' + indent do + whitelist.each do |key, values| + puts "#{key.ljust(40)} : #{values['name']}" + end + end + end + + def print_lights(lights) + puts 'Lights:' + indent do + lights.each do |key, values| + state_str = values.dig('state', 'on') ? 'ON' : 'OFF' + puts " #{key.rjust(3)}. #{values['name'].ljust(30)} \t #{state_str} \t " \ + "#{values['modelid']} - #{values['type']}" + end + end + end + end +end diff --git a/hue/lib/hue/extensions/bulb.rb b/hue/lib/hue/extensions/bulb.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative '../utilities/indentation' + +module Hue + class Bulb + include Indentation + + def print_state + spaces = 4 + status = on? ? 'ON' : 'OFF' + puts "#{id}.".ljust(spaces) + "#{name}: #{status}" + + indent(spaces) do + print_brightness + print_color + print_transitory_state + end + end + + private + + def print_brightness + puts "Brightness: #{brightness} (#{brightness_percent.to_i}%)" + end + + def print_color + return unless state['hue'] + + puts "Color: #{color}, #{color.to_rgb}" + end + + def print_transitory_state + transitory = [ + (transition_time.positive? ? "Transition time: #{transition_time} sec" : nil), + (blinking? ? 'Alert: Blinking!' : nil), + (effect? ? "Effect: #{effect}" : nil) + ].compact + + puts transitory.join(', ') unless transitory.empty? + end + end +end diff --git a/hue/lib/hue/extensions/hue.rb b/hue/lib/hue/extensions/hue.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Hue + def self.device_type + 'hue-cli' + end +end diff --git a/hue/lib/hue/utilities/indentation.rb b/hue/lib/hue/utilities/indentation.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Indentation + DEFAULT_INDENTATION_SIZE = 2 + + class << self + attr_accessor :indentation_size, :indentation_char, :indentation_level + + def reset + @indentation_size = 0 + @indentation_char = ' ' + @indentation_level = 0 + end + end + + reset + + def indent(size = DEFAULT_INDENTATION_SIZE) + original_size = Indentation.indentation_size + Indentation.indentation_size = size + Indentation.indentation_level += 1 + yield + ensure + Indentation.indentation_size = original_size + Indentation.indentation_level -= 1 + end + + def puts(*message) + message.each do |line| + print(Indentation.indentation_char * Indentation.indentation_size * Indentation.indentation_level) + super(line) + end + end +end diff --git a/hue/pkg/hue-0.1.0.gem b/hue/pkg/hue-0.1.0.gem Binary files differ. diff --git a/hue/spec/cli_spec.rb b/hue/spec/cli_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Hue::CLI do + it 'can be loaded' do + expect(Hue::CLI).to be_a(Module) + end + + it 'has valid commands' do + # Trigger object space loading + Hue::CLI.send(:commands_in_object_space) + # Just ensure it doesn't crash + end +end diff --git a/hue/spec/config/applications.yml b/hue/spec/config/applications.yml @@ -0,0 +1,4 @@ +--- +default: + bridge_id: bc6be180-4c57-0130-8d8f-0018de9ecdd0 + id: application_uuid diff --git a/hue/spec/config/bridges.yml b/hue/spec/config/bridges.yml @@ -0,0 +1,3 @@ +--- +bc6be180-4c57-0130-8d8f-0018de9ecdd0: + uri: http://localhost/api diff --git a/hue/spec/config/empty.yml b/hue/spec/config/empty.yml @@ -0,0 +1 @@ +--- {} diff --git a/hue/spec/hue/bridge_spec.rb b/hue/spec/hue/bridge_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper.rb' + +describe Hue::Bridge do + + context 'when instantiated' do + let(:bridge) { test_bridge } + + it 'should report the status' do + with_fake_request + expect(bridge.status).to eq(api_reply(:get_success)) + end + + it 'should report errors' do + with_fake_request(:lights, :unauthorized) + expect { + bridge.lights + }.to raise_error(Hue::API::Error, 'unauthorized user') + end + + it 'should report the available lights' do + with_fake_request(:lights) + expect(bridge.lights).to eq(api_reply(:lights)) + end + + it 'should report a simple string of light names' do + with_fake_request(:lights) + expect(bridge.light_names).to eq("1. Dining\n2. Bedroom Far\n3. Bedroom Near") + end + + it 'should report the bridge config' do + with_fake_request(:config) + expect(bridge.config).to eq(api_reply(:config)) + end + + it 'should report the light schedules' do + with_fake_request(:schedules) + expect(bridge.schedules).to eq(api_reply(:schedules)) + end + + it 'should return instance of all the bulbs' do + with_fake_request(:lights) + bulbs = bridge.bulbs + expect(bulbs.size).to eq(3) + bulbs.each do |bulb| + expect(bulb).to be_a(Hue::Bulb) + end + end + + it 'should allow unregistering an existing config' do + with_fake_delete("config/whitelist/#{TEST_APPLICATION_UUID}") + bridge.unregister + end + + it 'should allow scanning for new lights' do + with_fake_post("#{TEST_APPLICATION_UUID}/lights") + bridge.add_lights + end + end + + context 'when instantiated with a new config' do + let(:new_id) { 'new_test_id' } + let(:bridge) { described_class.new(new_id, TEST_BRIDGE_URI) } + + it 'should allow registering the new config' do + mock_socket_hostname + with_fake_post(nil, {:devicetype => "hue-lib@hostname"}) + bridge.register + end + end + +end diff --git a/hue/spec/hue/bulb_spec.rb b/hue/spec/hue/bulb_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper.rb' + +describe Hue::Bulb do + + context 'when instantiated with a given bridge and id' do + let(:bulb) { described_class.new(test_bridge, 1) } + + before(:each) do + with_fake_request('lights/1') + @test_status = api_reply('lights/1') + bulb.refresh! + end + + it 'should report the bulb state' do + expect(bulb.state).to eq(@test_status['state']) + end + + it 'should report the bulb info' do + info = api_reply('lights/1') + info.delete('state') + info.delete('pointsymbol') + expect(bulb.info).to eq(info) + end + + it "should report it's name" do + expect(bulb.name).to eq(@test_status['name']) + end + + it "should report if it's on" do + expect(bulb.on?).to be false + end + + it "should report if it's off" do + expect(bulb.off?).to be true + end + + it "should report the brightness and color mode" do + expect(bulb.brightness).to eq(146) + expect(bulb.bri).to eq(146) + expect(bulb.color_mode).to eq('ct') + expect(bulb.color_mode).to eq(bulb.colormode) + end + + it "should report the color" do + color = bulb.color + expect(color).to be_a(Hue::Colors::ColorTemperature) + expect(color.mired).to eq(459) + expect(color.kelvin).to eq(2179) + end + + it "should report the alert state" do + expect(bulb.alert).to eq('none') + expect(bulb.blinking?).to be false + expect(bulb.solid?).to be true + end + + it "should report the effect state" do + expect(bulb.effect?).to be false + expect(bulb.effect).to eq('none') + expect(bulb.color_loop?).to be false + end + + it "should report the transition time" do + expect(bulb.transition_time).to eq(0) + end + + context 'by changing state' do + + it 'should allow turning bulps on and off' do + with_fake_update('lights/1/state', on: true) + expect(bulb.on).to be true + + with_fake_update('lights/1/state', on: false) + expect(bulb.off).to be true + end + + it 'should allow changing the name' do + with_fake_update('lights/1', name: 'New name') + bulb.name = 'New name' + expect(bulb.name).to eq('New name') + end + + it 'should allow setting hue, saturation and brightness' do + color = Hue::Colors::HueSaturation.new(21845, 1293) + + with_fake_update('lights/1/state', hue: 21845, sat: 255) + set_color = (bulb.color = color) + expect(set_color.hue).to eq(21845) + expect(set_color.saturation).to eq(255) + end + + it 'should allow setting brightness as a number, percentage or string' do + with_fake_update('lights/1/state', bri: 233) + bulb.brightness = 233 + expect(bulb.brightness).to eq(233) + + with_fake_update('lights/1/state', bri: 128) + bulb.brightness = "50%" + expect(bulb.brightness).to eq(128) + expect(bulb.brightness_in_unit_interval).to eq(0.5019607843137255) + expect(bulb.brightness_percent).to eq(50) + + with_fake_update('lights/1/state', bri: 128) + bulb.brightness = "128" + expect(bulb.brightness).to eq(128) + end + + it 'should allow setting blink, solid and flash alerts' do + with_fake_update('lights/1/state', alert: 'lselect') + bulb.blink + expect(bulb.blinking?).to be true + + with_fake_update('lights/1/state', alert: 'none') + bulb.solid + expect(bulb.solid?).to be true + + with_fake_update('lights/1/state', alert: 'select') do + with_fake_update('lights/1/state', alert: 'none') + bulb.flash + expect(bulb.solid?).to be true + end + + with_fake_update('lights/1/state', alert: 'crap') do + bulb.alert = 'crap' + end + end + + it 'should allow setting colorloop, and effect' do + with_fake_update('lights/1/state', effect: 'new') + bulb.effect = 'new' + expect(bulb.effect).to eq('new') + expect(bulb.effect?).to be true + + with_fake_update('lights/1/state', effect: 'colorloop') + bulb.colorloop + expect(bulb.effect).to eq('colorloop') + expect(bulb.effect?).to be true + expect(bulb.color_loop?).to be true + + with_fake_update('lights/1/state', effect: 'none') + bulb.clear_effect + expect(bulb.effect).to eq('none') + + with_fake_update('lights/1/state', effect: 'colorloop') + bulb.color_loop + expect(bulb.effect).to eq('colorloop') + end + + it 'should allow setting the transitions time, and employ it for a state change' do + bulb.transition_time = 10 + + with_fake_update('lights/1/state', transitiontime: 100, bri: 255) + bulb.brightness = 255 + end + + end + + end + +end diff --git a/hue/spec/hue/colors/color_spec.rb b/hue/spec/hue/colors/color_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper.rb' + +describe Hue::Colors::Color do + + context 'implements a ranged method: max > value > min' do + it 'should return the min when value < min' do + expect(described_class.ranged(0, -1, 2)).to eq(0) + end + + it 'should return the max when value > max' do + expect(described_class.ranged(0, 3, 2)).to eq(2) + end + + it 'should return the value when max > value > min' do + expect(described_class.ranged(0, 1, 2)).to eq(1) + end + end + + def abstract_method(method) + abstract_color = described_class.new + expect(abstract_color).to respond_to(method) + expect { + abstract_color.send(method) + }.to raise_error(described_class::ERROR_METHOD_NOT_IMPLEMENTED) + end + + context 'defines but does not implement methods:' do + it('#to_hash') { abstract_method(:to_hash) } + it('#to_s') { abstract_method(:to_s) } + it('#to_rgb') { abstract_method(:to_rgb) } + end + +end diff --git a/hue/spec/hue/colors/color_temperature_spec.rb b/hue/spec/hue/colors/color_temperature_spec.rb @@ -0,0 +1,106 @@ +# Encoding: UTF-8 +require 'spec_helper.rb' + +describe Hue::Colors::ColorTemperature do + + context 'when initialized with a valid value' do + let(:color) { described_class.new(500) } + + it 'should report the temperature in mireds and kelvins' do + expect(color.mired).to eq(500) + expect(color.kelvin).to eq(2000) + end + + it 'should have a string representation' do + expect(color.to_s).to eq("Temperature=2000°K (500 mired)") + end + + it 'should have a hash representation' do + expect(color.to_hash).to eq({ct: 500}) + end + + it 'should have an RGB representation' do + expect(color.to_rgb).to eq(Hue::Colors::RGB.new(255,136,13)) + end + + context 'when allowing change to the temperature value' do + it 'should go to the max in kelvins' do + color.kelvin = 7000 + expect(color.kelvin).to eq(described_class::KELVIN_MAX) + expect(color.mired).to eq(described_class::MIRED_MIN) + end + + it 'should go to the max in mireds' do + color.mired = 600 + expect(color.mired).to eq(described_class::MIRED_MAX) + expect(color.kelvin).to eq(described_class::KELVIN_MIN) + end + + it 'should go to the min in mireds' do + color.mired = 100 + expect(color.mired).to eq(described_class::MIRED_MIN) + expect(color.kelvin).to eq(described_class::KELVIN_MAX) + end + + it 'should go to the min in kelvins' do + color.mired = 2000 + expect(color.mired).to eq(described_class::MIRED_MAX) + expect(color.kelvin).to eq(described_class::KELVIN_MIN) + end + + it 'should hit the middle' do + mired_middle = described_class::MIRED_MAX/2 + color.mired = mired_middle + expect(color.mired).to eq(mired_middle) + expect(color.kelvin).to eq(4000) + + kelvin_middle = described_class::KELVIN_MAX/2 + color.kelvin = kelvin_middle + expect(color.kelvin).to eq(kelvin_middle) + expect(color.mired).to eq(307) + end + + end + end + + context 'when initializing' do + it 'should set the temperature depending on the value' do + color = described_class.new(100) + expect(color.mired).to eq(described_class::MIRED_MIN) + + color = described_class.new(600) + expect(color.mired).to eq(described_class::MIRED_MAX) + + color = described_class.new(described_class::KELVIN_MIN) + expect(color.kelvin).to eq(described_class::KELVIN_MIN) + + color = described_class.new(described_class::KELVIN_MAX + 3000) + expect(color.kelvin).to eq(described_class::KELVIN_MAX) + end + + it 'should report kelvins as an integer' do + color = described_class.new(459) + expect(color.kelvin).to eq(2179) + end + + it 'should accept a string value for the temperature' do + color = described_class.new("100") + expect(color.mired).to eq(described_class::MIRED_MIN) + + color = described_class.new((described_class::KELVIN_MAX + 3000).to_s) + expect(color.kelvin).to eq(described_class::KELVIN_MAX) + end + + it 'should allow a percentage value for the mired scale' do + color = described_class.new("0%") + expect(color.mired).to eq(described_class::MIRED_MIN) + + color = described_class.new("50%") + expect(color.mired).to eq(326) + + color = described_class.new("100%") + expect(color.mired).to eq(described_class::MIRED_MAX) + end + end + +end diff --git a/hue/spec/hue/colors/hue_saturation_spec.rb b/hue/spec/hue/colors/hue_saturation_spec.rb @@ -0,0 +1,91 @@ +# Encoding: UTF-8 +require 'spec_helper.rb' + +describe Hue::Colors::HueSaturation do + + context 'when initialized with valid values' do + let(:color) { described_class.new(2e4, 122) } + + it 'should report those values' do + expect(color.hue).to eq(20_000) + expect(color.saturation).to eq(122) + end + + it 'should report hue in degrees and a closed unit interval' do + expect(color.hue_in_degrees).to eq(109.86328125) + expect(color.hue_in_unit_interval).to eq(0.30517578125) + end + + it 'should report saturation in a closed unit interval' do + expect(color.saturation_in_unit_interval).to eq(0.47843137254901963) + end + + it 'should have a string representation' do + expect(color.to_s).to eq("Hue=20000, Saturation=122") + end + + it 'should have a hash representation' do + expect(color.to_hash).to eq({hue: 20_000, sat: 122}) + end + + it 'should have an RGB representation' do + expect(color.to_rgb).to eq(Hue::Colors::RGB.new(153,255,132)) + end + + context 'when allowing change to the color values' do + it 'should allow setting the maximum hue' do + color.hue = 70_000 + expect(color.hue).to eq(described_class::HUE_MAX) + expect(color.hue_in_degrees).to eq(360) + expect(color.hue_in_unit_interval).to eq(1.0) + end + + it 'should allow setting the minimum hue' do + color.hue = -1000 + expect(color.hue).to eq(described_class::HUE_MIN) + expect(color.hue_in_degrees).to eq(0) + expect(color.hue_in_unit_interval).to eq(0.0) + end + + it 'should allow setting the maximum saturation' do + color.saturation = 300 + expect(color.sat).to eq(described_class::SATURATION_MAX) + expect(color.sat_in_unit_interval).to eq(1.0) + end + + it 'should allow setting the minimum saturation' do + color.saturation = -1 + expect(color.sat).to eq(described_class::SATURATION_MIN) + expect(color.sat_in_unit_interval).to eq(0.0) + end + end + + context 'when initialized with other values' do + it 'should accept hue and saturation values' do + color = described_class.new(3e4, 200) + expect(color.hue).to eq(3e4) + expect(color.sat).to eq(200) + end + + it 'should accept a percentage of the hue scale with saturation value' do + color = described_class.new('20%', 150) + expect(color.hue).to eq(13107) + expect(color.sat).to eq(150) + end + + it 'should accept a hue value with a percentage of the saturation scale' do + color = described_class.new(7e4, '100%') + expect(color.hue).to eq(Hue::Colors::HueSaturation::HUE_MAX) + expect(color.sat).to eq(Hue::Colors::HueSaturation::SATURATION_MAX) + end + + it 'should accept hue and saturation strings' do + color = described_class.new("30_000", "1333") + expect(color.hue).to eq(3e4) + expect(color.sat).to eq(Hue::Colors::HueSaturation::SATURATION_MAX) + end + end + + end + +end diff --git a/hue/spec/hue/colors/rgb_spec.rb b/hue/spec/hue/colors/rgb_spec.rb @@ -0,0 +1,64 @@ +# Encoding: UTF-8 +require 'spec_helper.rb' + +describe Hue::Colors::RGB do + + context 'when initialized with a valid values' do + let(:color) { described_class.new(64, 128, 248) } + + it 'should report those values' do + expect(color.red).to eq(64) + expect(color.green).to eq(128) + expect(color.blue).to eq(248) + end + + it 'should have a string representation' do + expect(color.to_s).to eq("RGB≈[64, 128, 248]") + end + + it 'should have a hash representation' do + expect(color.to_hash).to eq({hue: 21845, sat: 127, bri: 255 }) + end + + it 'should have an RGB representation' do + expect(color.to_rgb).to eq(color) + end + + context 'when allowing change to the color values' do + it 'should allow setting the maximum' do + color.red = 300 + expect(color.red).to eq(described_class::MAX) + color.green = 255 + expect(color.green).to eq(described_class::MAX) + color.red = 256 + expect(color.red).to eq(described_class::MAX) + end + + it 'should allow setting the minimum' do + color.red = 0 + expect(color.red).to eq(described_class::MIN) + color.green = -1 + expect(color.green).to eq(described_class::MIN) + color.red = -256 + expect(color.red).to eq(described_class::MIN) + end + end + + context 'when initializing' do + it 'should allow strings' do + color = described_class.new('200', '100', '50') + expect(color.red).to eq(200) + expect(color.green).to eq(100) + expect(color.blue).to eq(50) + end + + it 'should allow percentages' do + color = described_class.new('10%', '100%', '50%') + expect(color.red).to eq(26) + expect(color.green).to eq(255) + expect(color.blue).to eq(128) + end + end + end + +end diff --git a/hue/spec/hue/colors/xy_spec.rb b/hue/spec/hue/colors/xy_spec.rb @@ -0,0 +1,51 @@ +# Encoding: UTF-8 +require 'spec_helper.rb' + +describe Hue::Colors::XY do + + context 'when initialized with a valid values' do + let(:color) { described_class.new(0.5, 0.5) } + + it 'should report those values' do + expect(color.x).to eq(0.5) + expect(color.y).to eq(0.5) + end + + it 'should have a string representation' do + expect(color.to_s).to eq("XY=[0.5, 0.5]") + end + + it 'should have a hash representation' do + expect(color.to_hash).to eq({xy: [0.5, 0.5]}) + end + + it 'should have an RGB representation' do + expect(color.to_rgb).to eq(Hue::Colors::RGB.new(217,209,41)) + end + + context 'when allowing change to the color values' do + it 'should allow setting the maximum' do + color.x = 1.1 + expect(color.x).to eq(described_class::MAX) + color.y = 2 + expect(color.y).to eq(described_class::MAX) + end + + it 'should allow setting the minimum' do + color.x = -1 + expect(color.x).to eq(described_class::MIN) + color.y = 0 + expect(color.y).to eq(described_class::MIN) + end + end + + context 'when initializing' do + it 'should allow strings' do + color = described_class.new('0.22', '10.00') + expect(color.x).to eq(0.22) + expect(color.y).to eq(1.00) + end + end + end + +end diff --git a/hue/spec/hue/colors_spec.rb b/hue/spec/hue/colors_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper.rb' + +describe Hue::Colors do + + context 'when #parse_state is used' do + it 'can return a ColorTemperature' do + color = described_class.parse_state({'colormode' => 'ct', 'ct' => 500}) + expect(color).to be_a(Hue::Colors::ColorTemperature) + expect(color.mired).to eq(500) + end + + it 'can return HueSaturation' do + color = described_class.parse_state({'colormode' => 'hs', 'hue' => 30_000, 'sat' => 150}) + expect(color).to be_a(Hue::Colors::HueSaturation) + expect(color.hue).to eq(30_000) + expect(color.sat).to eq(150) + end + + it 'can return XY' do + color = described_class.parse_state({'colormode' => 'xy', 'xy' => [0.8, 0.2]}) + expect(color).to be_a(Hue::Colors::XY) + expect(color.x).to eq(0.8) + expect(color.y).to eq(0.2) + end + + it 'throws error if no mode is passed' do + expect { + described_class.parse_state({}) + }.to raise_error(Hue::Error, /Unknown or missing state/) + end + end + + context 'when #parse is used' do + it 'can return a ColorTemperature with single value' do + color = described_class.parse("5000") + expect(color).to be_a(Hue::Colors::ColorTemperature) + expect(color.kelvin).to eq(5000) + + color = described_class.parse(130) + expect(color).to be_a(Hue::Colors::ColorTemperature) + expect(color.mired).to eq(Hue::Colors::ColorTemperature::MIRED_MIN) + + color = described_class.parse("100%") + expect(color).to be_a(Hue::Colors::ColorTemperature) + expect(color.mired).to eq(Hue::Colors::ColorTemperature::MIRED_MAX) + end + + it 'can return HueSaturation' do + color = described_class.parse(*[3e4, 200]) + expect(color).to be_a(Hue::Colors::HueSaturation) + expect(color.hue).to eq(3e4) + expect(color.sat).to eq(200) + + color = described_class.parse(*['20%', 150]) + expect(color).to be_a(Hue::Colors::HueSaturation) + expect(color.hue).to eq(13107) + expect(color.sat).to eq(150) + + color = described_class.parse(*[7e4, '100%']) + expect(color).to be_a(Hue::Colors::HueSaturation) + expect(color.hue).to eq(Hue::Colors::HueSaturation::HUE_MAX) + expect(color.sat).to eq(Hue::Colors::HueSaturation::SATURATION_MAX) + end + + it 'can return XY' do + color = described_class.parse(*[0.2, 0.5]) + expect(color).to be_a(Hue::Colors::XY) + expect(color.x).to eq(0.2) + expect(color.y).to eq(0.5) + end + + it 'can return RGB' do + color = described_class.parse(*[0.0, 300, 155]) + expect(color).to be_a(Hue::Colors::RGB) + expect(color.red).to eq(0) + expect(color.green).to eq(255) + expect(color.blue).to eq(155) + + color = described_class.parse(*['10%', '100%', '50%']) + expect(color).to be_a(Hue::Colors::RGB) + expect(color.red).to eq(26) + expect(color.green).to eq(255) + expect(color.blue).to eq(128) + end + + it 'throws error if no mode is passed' do + expect { + described_class.parse + }.to raise_error(Hue::Error, /Unable to parse to color:/) + end + end +end diff --git a/hue/spec/hue/config/abstract_spec.rb b/hue/spec/hue/config/abstract_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper.rb' + +class Hue::Config::AbstractTest < Hue::Config::Abstract + private + def add_self_to_yaml(yaml) + yaml[name] = { 1 => :test} + end +end + +describe Hue::Config::Abstract do + + before(:all) do + create_test_application_config + end + + context 'given an new config' do + let(:config) { described_class.new('test', TEST_CONFIG_APPLICATION_PATH) } + + it 'should report the values' do + expect(config.name).to eq('test') + expect(config.path).to eq(TEST_CONFIG_APPLICATION_PATH) + end + + it 'should allow writing the new config to file' do + config.write + expect(YAML.load_file(config.path)['test']).to be_a(Hash) + end + + it 'should allow deleting that named config from the file' do + config.delete + expect(YAML.load_file(config.path)['test']).to be_nil + end + end + + context 'given an existing config' do + let(:config) { described_class.new('test', EMPTY_CONFIG_FILE) } + + it 'should allow writing to the file' do + config.write + expect(YAML.load_file(config.path)['test']).to be_a(Hash) + config.delete + end + end + +end diff --git a/hue/spec/hue/config/application_spec.rb b/hue/spec/hue/config/application_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe Hue::Config::Application do + + before(:each) do + mock_application_config_path + end + + after(:all) do + create_test_application_config + end + + it 'should report the config file location' do + expect(described_class.file_path).to eq(TEST_CONFIG_APPLICATION_PATH) + end + + it "should throw and error if a named config doesn't exist" do + expect { + described_class.named('not_default') + }.to raise_error(Hue::Config::NotFound, /Config named (.*) not found/) + end + + context 'with a config file, containing a default' do + let(:config) { described_class.default } + + it "should give the default config and report it's values" do + expect(config.name).to eq(described_class::STRING_DEFAULT) + expect(config.bridge_id).to eq(TEST_CONFIG_APPLICATION[config.name][described_class::STRING_BRIDGE_ID]) + expect(config.id).to eq(TEST_CONFIG_APPLICATION[config.name][described_class::STRING_ID]) + end + + it 'should allow deleting the default config from the file' do + config.delete + expect(YAML.load_file(described_class.file_path)[described_class::STRING_DEFAULT]).to be_nil + end + end + + context 'given an new config' do + let(:config) { described_class.new('http://someip/api', 'some_id', 'not_default') } + + it 'should report the values' do + expect(config.name).to eq('not_default') + expect(config.bridge_id).to eq('http://someip/api') + expect(config.id).to eq('some_id') + end + + it 'should allow writing the new config to file' do + config.write + expect(YAML.load_file(described_class.file_path)['not_default']).to be_a(Hash) + end + + it 'should allow fetching that name config' do + named_config = described_class.named('not_default') + expect(named_config).to eq(config) + end + + it 'should allow deleting that named config from the file' do + config.delete + expect(YAML.load_file(described_class.file_path)['not_default']).to be_nil + end + end + + context 'given an non existing config' do + + it "should throw and error if a named config doesn't exist" do + expect(described_class).to receive(:file_path).and_return(EMPTY_CONFIG_FILE).once + + expect { + described_class.named('not_default') + }.to raise_error(Hue::Config::NotFound, /Config named (.*) not found/) + end + end + +end diff --git a/hue/spec/hue/config/bridge_spec.rb b/hue/spec/hue/config/bridge_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper.rb' + +describe Hue::Config::Bridge do + + before(:each) do + mock_bridge_config_path + end + + after(:all) do + create_test_bridge_config + end + + it 'should report the config file location' do + expect(described_class.file_path).to eq(TEST_CONFIG_BRIDGE_PATH) + end + + it 'should find existing bridges given an id' do + found = described_class.find(TEST_BRIDGE_UUID) + expect(found.id).to eq(TEST_BRIDGE_UUID) + expect(found.uri).to eq(TEST_BRIDGE_URI) + + expect(described_class.find('something')).to be_nil + end + + context 'given an new config' do + let(:uuid) { Hue.one_time_uuid } + let(:uri) { 'http://someip/api' } + let(:config) { described_class.new(uuid, uri) } + + it 'should report the values' do + expect(config.name).to eq(uuid) + expect(config.id).to eq(uuid) + expect(config.uri).to eq(uri) + end + end + +end diff --git a/hue/spec/hue_spec.rb b/hue/spec/hue_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper.rb' + +describe Hue do + before(:each) do + mock_application_config_path + mock_bridge_config_path + end + + after(:each) do + create_test_bridge_config + end + + before(:each) do + mock_udp_replies + end + + it 'should report the device type as itself' do + expect(described_class.device_type).to eq(described_class::DEVICE_TYPE) + end + + it 'should return the default application' do + expect(described_class.application).to be_a(described_class::Bridge) + end + + context 'when discovering a bridge on the network' do + it 'should return a list discovered bridges' do + bridges = Hue.discover + expect(bridges).to eq({TEST_UDP_BRIDGE_UUID => TEST_UDP_BRIDGE_URI}) + end + + it 'should allow registering of discovered bridges' do + expect(Hue::Config::Bridge.find(TEST_UDP_BRIDGE_UUID)).to be_nil + registered = Hue.register_bridges + new_bridge = registered[TEST_UDP_BRIDGE_UUID] + expect(new_bridge.id).to eq(TEST_UDP_BRIDGE_UUID) + expect(new_bridge.uri).to eq(TEST_UDP_BRIDGE_URI) + end + end + + context 'when attempting discover without a bridge on the network' do + before(:each) do + mock_udp_no_reply + mock_nupnp_empty_reply + end + + it 'should return an empty list of bridges' do + bridges = Hue.discover + expect(bridges).to eq({}) + end + end + + context 'after discovering bridges' do + before(:each) do + mock_udp_replies(TEST_BRIDGE_UUID, 'new_host') + end + + it 'should update already registered bridges' do + bridge = Hue::Config::Bridge.find(TEST_BRIDGE_UUID) + expect(bridge).not_to be_nil + + registered = Hue.register_bridges + + updated_bridge = registered[TEST_BRIDGE_UUID] + expect(updated_bridge.id).to eq(TEST_BRIDGE_UUID) + expect(updated_bridge.uri).not_to eq(bridge.uri) + end + end + + context 'when registering or un-registering the application' do + it 'should throw and error if the default already exists' do + expect { + described_class.register_default + }.to raise_error(described_class::Error, described_class::ERROR_DEFAULT_EXISTS) + end + + it 'should allow a new default if one doesn\'t exist' do + with_temp_config_path do + with_fake_post(nil, {}, 'post_success', TEST_UDP_BRIDGE_URI) + with_stdout(/Registering new app...$/) do + described_class.register_default + end + end + end + + it 'should allow un-registering the default' do + with_temp_config_path(true) do + with_fake_delete("config/whitelist/#{TEST_APPLICATION_UUID}") + described_class.remove_default + end + end + end + + context 'contains some utility methods' do + it '#percent_to_unit_interval should convert a string with percent to a unit interval' do + expect(described_class.percent_to_unit_interval('1%')).to eq(0.01) + expect(described_class.percent_to_unit_interval('10%')).to eq(0.1) + expect(described_class.percent_to_unit_interval('100%')).to eq(1.00) + end + end + +end diff --git a/hue/spec/json/application_uuid/lights/post_success.json b/hue/spec/json/application_uuid/lights/post_success.json @@ -0,0 +1,7 @@ +[ + { + "success":{ + "/lights":"Searching for new devices" + } + } +] diff --git a/hue/spec/json/config.json b/hue/spec/json/config.json @@ -0,0 +1,27 @@ +{ + "name" : "Philips hue", + "mac" : "00:17:88:09:00:00", + "dhcp" : true, + "ipaddress" : "10.255.255.44", + "netmask" : "255.255.255.0", + "gateway" : "10.255.255.1", + "proxyaddress" : "", + "proxyport" : 0, + "UTC" : "2012-11-02T18:34:35", + "whitelist" : { + "0123456789abdcef0123456789abcdef" : { + "last use date" : "2012-11-02T18:34:35", + "create date" : "2012-11-01T21:04:48", + "name" : "iPhone 5" + } + }, + "swversion" : "01003542", + "swupdate" : { + "updatestate" : 0, + "url" : "", + "text" : "", + "notify" : false + }, + "linkbutton" : false, + "portalservices" : false +} diff --git a/hue/spec/json/config/whitelist/application_uuid/delete_success.json b/hue/spec/json/config/whitelist/application_uuid/delete_success.json @@ -0,0 +1 @@ +[{}] diff --git a/hue/spec/json/get_success.json b/hue/spec/json/get_success.json @@ -0,0 +1,117 @@ +{ + "lights" : { + "1" : { + "state" : { + "on" : false, + "bri" : 146, + "hue" : 13234, + "sat" : 208, + "xy" : [ "0.5090", "0.4149" ], + "ct" : 459, + "alert" : "none", + "effect" : "none", + "colormode" : "ct", + "reachable" : true + }, + "type" : "Extended color light", + "name" : "Living", + "modelid" : "LCT001", + "swversion" : "65003148", + "pointsymbol" : { + "1" : "none", + "2" : "none", + "3" : "none", + "4" : "none", + "5" : "none", + "6" : "none", + "7" : "none", + "8" : "none" + } + }, + "2" : { + "state" : { + "on" : false, + "bri" : 162, + "hue" : 2213, + "sat" : 238, + "xy" : [ "0.6349", "0.3413" ], + "ct" : 500, + "alert" : "none", + "effect" : "none", + "colormode" : "xy", + "reachable" : true + }, + "type" : "Extended color light", + "name" : "Bedroom Far", + "modelid" : "LCT001", + "swversion" : "65003148", + "pointsymbol" : { + "1" : "none", + "2" : "none", + "3" : "none", + "4" : "none", + "5" : "none", + "6" : "none", + "7" : "none", + "8" : "none" + } + }, + "3" : { + "state" : { + "on" : false, + "bri" : 146, + "hue" : 13122, + "sat" : 211, + "xy" : [ "0.3565", "0.1775" ], + "ct" : 462, + "alert" : "none", + "effect" : "none", + "colormode" : "ct", + "reachable" : true + }, + "type" : "Extended color light", + "name" : "Bedroom Near", + "modelid" : "LCT001", + "swversion" : "65003148", + "pointsymbol" : { + "1" : "none", + "2" : "none", + "3" : "none", + "4" : "none", + "5" : "none", + "6" : "none", + "7" : "none", + "8" : "none" + } + } + }, + "groups" : { }, + "config" : { + "name" : "Philips hue", + "mac" : "00:17:88:09:26:9d", + "dhcp" : true, + "ipaddress" : "10.255.255.44", + "netmask" : "255.255.255.0", + "gateway" : "10.255.255.254", + "proxyaddress" : "", + "proxyport" : 0, + "UTC" : "2012-11-02T18:34:38", + "whitelist" : { + "0123456789abdcef0123456789abcdef" : { + "last use date" : "2012-11-02T18:34:38", + "create date" : "2012-11-01T21:04:48", + "name" : "iPhone 5" + } + }, + "swversion" : "01003542", + "swupdate" : { + "updatestate" : 0, + "url" : "", + "text" : "", + "notify" : false + }, + "linkbutton" : false, + "portalservices" : false + }, + "schedules" : { } +} diff --git a/hue/spec/json/lights.json b/hue/spec/json/lights.json @@ -0,0 +1,14 @@ +{ + "1": + { + "name": "Dining" + }, + "2": + { + "name": "Bedroom Far" + }, + "3": + { + "name": "Bedroom Near" + } +} diff --git a/hue/spec/json/lights/1.json b/hue/spec/json/lights/1.json @@ -0,0 +1,28 @@ +{ + "state" : { + "on" : false, + "bri" : 146, + "hue" : 13234, + "sat" : 208, + "xy" : [ "0.5090", "0.4149" ], + "ct" : 459, + "alert" : "none", + "effect" : "none", + "colormode" : "ct", + "reachable" : true + }, + "type" : "Extended color light", + "name" : "Living", + "modelid" : "LCT001", + "swversion" : "65003148", + "pointsymbol" : { + "1" : "none", + "2" : "none", + "3" : "none", + "4" : "none", + "5" : "none", + "6" : "none", + "7" : "none", + "8" : "none" + } +} diff --git a/hue/spec/json/post_error.json b/hue/spec/json/post_error.json @@ -0,0 +1 @@ +{"error"=>{"type"=>101, "address"=>"", "description"=>"link button not pressed"}} diff --git a/hue/spec/json/post_success.json b/hue/spec/json/post_success.json @@ -0,0 +1,8 @@ +[ + { + "success": + { + "username":"new_test_id" + } + } +] diff --git a/hue/spec/json/put_success.json b/hue/spec/json/put_success.json @@ -0,0 +1 @@ +[{}] diff --git a/hue/spec/json/schedules.json b/hue/spec/json/schedules.json @@ -0,0 +1,14 @@ +{ + "1" : { + "name" : "Timer on f 649863 " + }, + "2" : { + "name" : "Timer on 807548 " + }, + "3" : { + "name" : "Timer on f 996556 " + }, + "4" : { + "name" : "Timer on 208820 " + } +} diff --git a/hue/spec/json/unauthorized.json b/hue/spec/json/unauthorized.json @@ -0,0 +1,10 @@ +[ + { + "error": + { + "type":1, + "address":"/some_address", + "description":"unauthorized user" + } + } +] diff --git a/hue/spec/spec_helper.rb b/hue/spec/spec_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'hue' +require 'rspec' +require 'webmock/rspec' + +RSpec.configure do |config| + config.mock_with :rspec +end + +WebMock.disable_net_connect! diff --git a/justfile b/justfile @@ -0,0 +1,54 @@ +bazel := "bazelisk" +cargo := "cargo" + +# Show this help message +default: + @just --list + +# Generate schema for `rust-analyzer` +rust-gen-project: + {{bazel}} run @rules_rust//tools/rust_analyzer:gen_rust_project + +# Update `requirements.out` lockfile +pip-deps-update: + {{bazel}} run //:requirements.update + +# Update `Cargo.lock` lockfile +cargo-deps-update: + {{cargo}} generate-lockfile + +# Run `cargo check` (using bazel clippy aspect) +clippy +targets: + {{bazel}} build --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect {{ "//" + replace(targets, " ", " //") }} + +# Format with `rustfmt` +rustfmt +targets: + {{bazel}} build --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect {{ "//" + replace(targets, " ", " //") }} + +# Run `bazel run //<target>` +run +targets: + @for target in {{targets}}; do \ + {{bazel}} run //"${target#//}"; \ + done + +# Run `bazel build //<target>` +build +targets: + {{bazel}} build {{ "//" + replace(targets, " ", " //") }} + +# Run `bazel build //<target>:deploy` +deploy +targets: + @for target in {{targets}}; do \ + {{bazel}} run //"${target#//}:deploy"; \ + done + +# Run `bazel fetch //...` +fetch: + {{bazel}} fetch //... + +# Run `bazel clean --expunge` +clean: + {{bazel}} clean --expunge + +# Run `bazel mod tidy` +tidy: + {{bazel}} mod tidy diff --git a/khal_notifier/.formatter.exs b/khal_notifier/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/khal_notifier/.gitignore b/khal_notifier/.gitignore @@ -0,0 +1,4 @@ +/.expert/ +/_build/ +/deps/ +khal_notifier diff --git a/khal_notifier/AGENTS.md b/khal_notifier/AGENTS.md @@ -0,0 +1,153 @@ +# Khal Notifier - Agent Documentation + +## Project Overview + +Khal Notifier is an Elixir application that syncs calendars with vdirsyncer and sends desktop notifications for upcoming khal calendar events. It's designed as a command-line tool that runs periodically to alert users about imminent calendar events. + +## Architecture + +The application is built as a single Elixir module that: +1. Optionally syncs calendars using `vdirsyncer` +2. Queries upcoming events from `khal` (a calendar application) +3. Filters events to exclude those that have already started +4. Sends desktop notifications using `notify-send` + +## Essential Commands + +### Building +The project provides automatic build selection based on environment: + +```bash +# Automatically chooses best build method +make build + +# Force specific build methods +make build-burrito # For static binaries (non-Nix) +make build-escript # For Nix compatibility + +# Or manually: +mix deps.get +mix escript.build # Works in Nix +# OR +mix release # Works outside Nix +``` + +### Development +```bash +# Format code +make format + +# Lint code (strict mode) +make lint + +# Type checking +make check + +# Clean build artifacts +make clean +``` + +### Running +After building, the binary will be located at: +``` +_build/dev/rel/khal_notifier/burrito_out/khal_notifier +``` + +Or for production: +``` +_build/prod/rel/khal_notifier/burrito_out/khal_notifier +``` + +## Command Line Interface + +The application accepts these command-line arguments: + +- `--lookahead TIME` - How far ahead to check for events (default: "15m") +- `--urgency LEVEL` - Notification urgency level (default: "critical") +- `--skip-sync` - Skip the vdirsyncer sync step +- `--clean` - Clean operation (see source for details) +- `--refresh SECONDS` - Run continuously, re-checking every N seconds (internal cron mode) + +Example: +```bash +# Run once +./khal_notifier --lookahead "30m" --urgency "normal" --skip-sync + +# Run continuously, checking every 5 minutes +./khal_notifier --refresh 300 + +# Run continuously, checking every 30 seconds +./khal_notifier --refresh 30 + +# Run continuously with custom lookahead +./khal_notifier --refresh 60 --lookahead "30m" +``` + +## Dependencies and External Tools + +The application depends on these external tools being installed and available on PATH: +- `vdirsyncer` - for calendar synchronization (optional if --skip-sync used) +- `khal` - for calendar event queries +- `notify-send` - for desktop notifications (libnotify) + +## Project Structure + +- `lib/khal_notifier.ex` - Main application module with all logic +- `mix.exs` - Mix project configuration (uses Burrito for static binaries) +- `Makefile` - Build automation +- `.formatter.exs` - Elixir code formatting rules +- `khal_notifier.service` - Systemd service file +- `khal_notifier.timer` - Systemd timer file + +## Build Configuration + +The project uses **Burrito** instead of escript to create static binaries. This allows: +- Single binary distribution without Elixir runtime dependencies +- Cross-platform compilation (Linux, macOS, Windows targets configured) +- Easier deployment and distribution + +## Testing Approach + +Currently no automated tests are set up. Manual testing involves: +1. Building the binary +2. Running with various command-line options +3. Verifying notifications appear correctly +4. Checking error handling when external tools are missing + +## Important Gotchas + +1. **Nix Environment**: The project automatically detects Nix environments and falls back to escript builds. Burrito builds may fail in Nix due to permission restrictions on ERTS files. + +2. **Build Output**: + - Burrito build: `_build/dev/rel/khal_notifier/burrito_out/khal_notifier` + - Escript build: `./khal_notifier` (in project root) + +3. **External Dependencies**: The application will fail silently if `khal` or `notify-send` are not available. Check logs for error messages. + +2. **Calendar Format**: The application expects `khal list` to output events in a specific format: `{start-time}|{end-time}|{title}|{organizer}`. This format is hardcoded. + +3. **Time Parsing**: Time comparison uses simple string comparison (HH:MM format), which works for same-day events but may not handle all edge cases. + +4. **Error Handling**: External command failures are logged but don't stop execution - the application continues with empty results. + +5. **Permissions**: For systemd integration (using the provided .service and .timer files), appropriate permissions are needed to access calendar files and send notifications. + +## Development Conventions + +- Use `mix format` before committing +- Run `make lint` to check code style with Credo (strict mode) +- Run `make check` for type analysis with Dialyxir +- Keep the main module focused - this is a simple utility tool +- Error handling should be robust but non-blocking + +## Deployment + +The project includes systemd service and timer files for automated execution: +- `khal_notifier.service` - Defines how to run the application +- `khal_notifier.timer` - Defines when to run the application (typically every few minutes) + +These files need to be installed in `/etc/systemd/system/` and enabled: +```bash +sudo cp khal_notifier.* /etc/systemd/system/ +sudo systemctl enable --now khal_notifier.timer +``` +\ No newline at end of file diff --git a/khal_notifier/BUILD b/khal_notifier/BUILD @@ -0,0 +1,13 @@ +load("//bazel:local-deploy.bzl", "local_deploy") + +# you have to `mix escript.build` first and then copy the binary to /opt/tools +filegroup( + name = "binary", + srcs = ["khal_notifier"], + visibility = ["//visibility:public"], +) + +local_deploy( + name = "deploy", + srcs = [":binary"], +) diff --git a/khal_notifier/Makefile b/khal_notifier/Makefile @@ -0,0 +1,24 @@ +.PHONY: all build-burrito build-escript format lint check clean + +all: build-escript + +build-burrito: + mix deps.get + mix release + +build-escript: + elixir build.exs + +format: + mix format + +lint: + mix credo --strict + +check: + mix dialyzer + +clean: + mix clean + rm -rf _build + rm -f khal_notifier diff --git a/khal_notifier/README.md b/khal_notifier/README.md @@ -0,0 +1,21 @@ +# KhalNotifier + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `khal_notifier` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:khal_libnotify, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at <https://hexdocs.pm/khal_libnotify>. + diff --git a/khal_notifier/build.exs b/khal_notifier/build.exs @@ -0,0 +1,29 @@ +#!/usr/bin/env elixir + +defmodule Build do + def main(_) do + # Ensure dependencies are installed + System.cmd("mix", ["deps.get"], cd: File.cwd!()) + + # Create a simple escript build as fallback for Nix + IO.puts("Building escript for Nix environment...") + + {output, 0} = System.cmd("mix", ["escript.build"], cd: File.cwd!) + IO.puts(output) + + # Check if binary was created + if File.exists?("khal_notifier") do + IO.puts("✅ Successfully built khal_notifier escript") + + # Make it executable + System.cmd("chmod", ["+x", "khal_notifier"]) + + IO.puts("Binary location: #{Path.absname("khal_notifier")}") + else + IO.puts("❌ Build failed - binary not found") + System.halt(1) + end + end +end + +Build.main(System.argv()) +\ No newline at end of file diff --git a/khal_notifier/flake.lock b/khal_notifier/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775525641, + "narHash": "sha256-rM50ZJFuc9ePeWX7mgkX3jyqIlJoAdhyGsz5d2xhaTk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "dc72a9d2282085d97d5a2cc8e7103a68ed4de188", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/khal_notifier/flake.nix b/khal_notifier/flake.nix @@ -0,0 +1,83 @@ +{ + description = "Khal Notifier - Calendar notification system"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Elixir and Erlang versions + beamPackages = pkgs.beam.packages.erlang_27; + elixir = beamPackages.elixir_1_19; + + pname = "khal_notifier"; + version = "0.1.0"; + + # Build dependencies (mix deps) + mixDeps = beamPackages.fetchMixDeps { + pname = "${pname}-deps"; + inherit version; + src = pkgs.lib.cleanSource ./.; + # You will need to update this hash when dependencies change + # Run `nix build` and use the hash from the error message + hash = "sha256-hpbDcEHtQKBPinqR+e+jjsnOcd5PC8WN16ecLyoLDio="; + }; + + khal_notifier = (beamPackages.buildMix.override { inherit elixir; }) { + name = pname; + inherit pname version mixDeps; + src = pkgs.lib.cleanSource ./.; + + nativeBuildInputs = [ pkgs.git pkgs.makeWrapper pkgs.cacert ]; + + buildPhase = '' + runHook preBuild + export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt + export SSL_CERT_DIR=${pkgs.cacert}/etc/ssl/certs + export MIX_HOME=$TMPDIR/.mix + + mkdir -p deps + cp -r ${mixDeps}/* deps/ || true + chmod -R +w deps/ || true + + mix compile --no-deps-check --no-archives-check + mix escript.build + runHook postBuild + ''; + + installPhase = '' + mkdir -p $out/bin + cp khal_notifier $out/bin/ + # Patch shebangs to use the nix-provided erlang/elixir + patchShebangs $out/bin/khal_notifier + ''; + }; + in + { + packages.default = khal_notifier; + + devShells.default = pkgs.mkShell { + buildInputs = [ + elixir + beamPackages.erlang + beamPackages.hex + beamPackages.rebar3 + pkgs.git + ]; + + shellHook = '' + echo "🗓️ Khal Notifier Development Environment" + echo "Elixir ${elixir.version} with Erlang ${beamPackages.erlang.version}" + echo "" + echo "Build the application with: nix escript.build" + echo "Run with: ./khal_notifier" + echo "Or build with Nix: nix build" + ''; + }; + }); +} diff --git a/khal_notifier/lib/BUILD b/khal_notifier/lib/BUILD @@ -0,0 +1,5 @@ +filegroup( + name = "all_srcs", + srcs = glob(["**/*.ex"]), + visibility = ["//khal_notifier:__pkg__"], +) diff --git a/khal_notifier/lib/khal_notifier.ex b/khal_notifier/lib/khal_notifier.ex @@ -0,0 +1,253 @@ +defmodule KhalNotifier do + @moduledoc """ + Sync calendars with vdirsyncer, then send notify-send + alerts for upcoming khal events. + """ + require Logger + + def main(args) do + {opts, _, _} = + OptionParser.parse(args, + switches: [ + lookahead: :string, + urgency: :string, + skip_sync: :boolean, + clean: :boolean, + refresh: :integer + ], + aliases: [] + ) + + lookahead = Keyword.get(opts, :lookahead, "15m") + urgency = Keyword.get(opts, :urgency, "critical") + skip_sync = Keyword.get(opts, :skip_sync, false) + refresh_seconds = Keyword.get(opts, :refresh, 300) + + run_with_refresh(refresh_seconds, lookahead, urgency, skip_sync) + end + + defp run_with_refresh(seconds, lookahead, urgency, skip_sync) do + Logger.info("Running in refresh mode: checking every #{seconds} second(s)") + loop_with_refresh(0, seconds, lookahead, urgency, skip_sync) + end + + defp loop_with_refresh(iteration, seconds, lookahead, urgency, skip_sync) do + if iteration > 0 do + Logger.info("Waiting #{seconds} second(s) before next refresh") + :timer.sleep(seconds * 1000) + end + + Logger.info("Run #{iteration + 1}") + run_once(lookahead, urgency, skip_sync) + + # Tail recursion ensures previous iteration's memory is freed + loop_with_refresh(iteration + 1, seconds, lookahead, urgency, skip_sync) + end + + defp run_once(lookahead, urgency, skip_sync) do + unless skip_sync do + unless sync_vdirsyncer() do + Logger.warning("Sync failed — continuing with local calendar data") + end + end + + events = get_upcoming_events(lookahead) + + events = filter_events(events) + + if Enum.empty?(events) do + Logger.info("No upcoming events") + else + Enum.each(events, fn event -> + summary = event.title + body = format_event(event) + Logger.info("Notifying: #{summary} — #{body}") + send_notification(summary, body, urgency) + end) + end + end + + defp filter_events(events) do + {now_time, 0} = System.cmd("date", ["+%H:%M"]) + now_time = String.trim(now_time) + + filtered = + Enum.reject(events, fn event -> + event.start_time != "" and event.start_time <= now_time + end) + + filtered + end + + defp run_cmd(cmd, args, timeout \\ 30_000) do + Logger.debug("Running: #{cmd} #{Enum.join(args, " ")}") + + case System.find_executable(cmd) do + nil -> + {:error, :enoent} + + path -> + try do + task = Task.async(fn -> System.cmd(path, args, stderr_to_stdout: true) end) + + case Task.yield(task, timeout) || Task.shutdown(task) do + {:ok, {output, exit_code}} -> + {:ok, output, exit_code} + + nil -> + {:error, :timeout} + end + rescue + e -> {:error, e} + end + end + end + + defp sync_vdirsyncer do + Logger.info("Syncing calendars with vdirsyncer") + + case run_cmd("vdirsyncer", ["sync"], 60_000) do + {:ok, _output, 0} -> + Logger.info("vdirsyncer sync complete") + true + + {:ok, output, code} -> + Logger.warning("vdirsyncer exited with code #{code}: #{String.trim(output)}") + false + + {:error, :enoent} -> + Logger.error("vdirsyncer not found") + false + + {:error, :timeout} -> + Logger.error("vdirsyncer timed out after 60 seconds") + false + + {:error, e} -> + Logger.error("vdirsyncer failed: #{inspect(e)}") + false + end + end + + defp get_upcoming_events(lookahead) do + Logger.info("Checking khal for events in the next #{lookahead}...") + + case run_cmd("khal", [ + "list", + "now", + lookahead, + "--format", + "{start-time}|{end-time}|{title}|{organizer}" + ]) do + {:ok, output, 0} -> + parse_events(output) + + {:ok, output, code} -> + Logger.warning("khal exited with code #{code}: #{String.trim(output)}") + [] + + {:error, :enoent} -> + Logger.error("khal not found. Is it installed and on PATH?") + [] + + {:error, :timeout} -> + Logger.error("khal timed out.") + [] + + {:error, _e} -> + Logger.error("khal execution failed.") + [] + end + end + + defp parse_events(output) do + events = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.filter(&String.contains?(&1, "|")) + |> Enum.map(fn line -> + case String.split(line, "|", parts: 4) do + [start_time, end_time, title, organizer] -> + %{ + start_time: String.trim(start_time), + end_time: String.trim(end_time), + title: String.trim(title), + organizer: String.trim(organizer) + } + + [start_time, end_time, title] -> + %{ + start_time: String.trim(start_time), + end_time: String.trim(end_time), + title: String.trim(title), + organizer: "" + } + + [start_time, title] -> + %{ + start_time: String.trim(start_time), + end_time: "", + title: String.trim(title), + organizer: "" + } + + [title] -> + %{ + start_time: "", + end_time: "", + title: String.trim(title), + organizer: "" + } + end + end) + |> Enum.reject(&(&1.title == "")) + + Logger.info("Found #{length(events)} upcoming event(s).") + events + end + + defp format_event(event) do + time_str = + case {event.start_time, event.end_time} do + {"", ""} -> "All day" + {start, ""} -> start + {start, end_t} -> "#{start} - #{end_t}" + end + + if Map.has_key?(event, :organizer) and event.organizer != "" do + "#{time_str}\nOrganizer: #{event.organizer}" + else + time_str + end + end + + defp send_notification(summary, body, urgency) do + args = [ + "--app-name", + "khal_notifier", + "--urgency", + urgency, + summary, + body + ] + + case run_cmd("notify-send", args) do + {:ok, _output, 0} -> + true + + {:ok, output, code} -> + Logger.error("notify-send failed (code #{code}): #{String.trim(output)}") + false + + {:error, :enoent} -> + Logger.error("notify-send not found") + false + + {:error, _} -> + Logger.error("notify-send failed") + false + end + end +end diff --git a/khal_notifier/mix.exs b/khal_notifier/mix.exs @@ -0,0 +1,41 @@ +defmodule KhalNotifier.MixProject do + use Mix.Project + + def project do + [ + app: :khal_notifier, + version: "0.1.0", + elixir: "~> 1.19", + start_permanent: Mix.env() == :prod, + escript: [main_module: KhalNotifier], + releases: [ + khal_notifier: [ + steps: [:assemble, &Burrito.wrap/1], + burrito: [ + targets: [ + linux: [os: :linux, cpu: :x86_64] + ], + quiet: false + ] + ] + ], + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:burrito, "~> 1.0"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/khal_notifier/mix.lock b/khal_notifier/mix.lock @@ -0,0 +1,18 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "burrito": {:hex, :burrito, "1.5.0", "d68ec01df2871f1d5bc603b883a78546c75761ac73c1bec1b7ae2cc74790fcd1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "3861abda7bffa733862b48da3e03df0b4cd41abf6fd24b91745f5c16d971e5fa"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, +} diff --git a/khal_notifier/rewrite_in_bb/example.bb b/khal_notifier/rewrite_in_bb/example.bb @@ -0,0 +1,111 @@ +#!/usr/bin/env bb + +(ns habitual + (:require [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [babashka.process :refer [shell]])) + +(defn parse-cron-field [field lo hi] + (let [all (set (range lo (inc hi)))] + (reduce + (fn [acc part] + (cond + (= part "*") + (into acc all) + + (re-matches #"\*/\d+" part) + (let [step (parse-long (subs part 2))] + (into acc (range lo (inc hi) step))) + + (re-matches #"\d+-\d+/\d+" part) + (let [[range-part step-part] (str/split part #"/") + [a b] (map parse-long (str/split range-part #"-")) + step (parse-long step-part)] + (into acc (range a (inc b) step))) + + (re-matches #"\d+-\d+" part) + (let [[a b] (map parse-long (str/split part #"-"))] + (into acc (range a (inc b)))) + + :else + (conj acc (parse-long part)))) + #{} + (str/split field #",")))) + +(defn parse-cron [expr] + (let [[minutes hours days months weekdays] (str/split expr #"\s+")] + {:minutes (parse-cron-field minutes 0 59) + :hours (parse-cron-field hours 0 23) + :days (parse-cron-field days 1 31) + :months (parse-cron-field months 1 12) + :weekdays (parse-cron-field weekdays 0 6)})) + +(defn next-cron-ms + "Returns milliseconds until the next matching habit" + [cron] + (let [now (System/currentTimeMillis) + start (-> now (quot 60000) inc (* 60000))] + (loop [t start] + (if (> t (+ start (* 4 366 24 60 60 1000))) + (throw (ex-info "No cron match found within 4 years" {:cron cron})) + (let [ldt (java.time.LocalDateTime/ofInstant + (java.time.Instant/ofEpochMilli t) + (java.time.ZoneId/systemDefault)) + month (.getMonthValue ldt) ; 1-12 + mday (.getDayOfMonth ldt) ; 1-31 + hour (.getHour ldt) ; 0-23 + minute (.getMinute ldt) ; 0-59 + weekday (-> ldt .getDayOfWeek .getValue (mod 7))] ; 0=Sun (ISO Mon=1, so Sun=7 mod 7=0) + (if (and ((:months cron) month) + ((:days cron) mday) + ((:weekdays cron) weekday) + ((:hours cron) hour) + ((:minutes cron) minute)) + (- t now) + (recur (+ t 60000)))))))) + +(defn run-job! [name cron prog] + (future + (loop [] + (let [delay-ms (next-cron-ms cron)] + (println (format "[INFO] Scheduling next run for '%s' in %d seconds" + name (quot delay-ms 1000))) + (Thread/sleep delay-ms) + (println (format "[INFO] Running notifier for '%s'" name)) + (let [result (shell {:continue true} prog name)] + (when (not= 0 (:exit result)) + (println (format "[WARN] Command returned %d: %s %s" + (:exit result) prog name)))) + (recur))))) + +(def cli-options + [["-p" "--prog PROG" "Program to send notifications to" + :default "notify-send"] + ["-h" "--help"]]) + +(defn -main [& args] + (let [{:keys [options summary errors]} (parse-opts args cli-options)] + (when errors + (println (str/join "\n" errors)) + (System/exit 1)) + (when (:help options) + (println "Usage: habitual [options]") + (println summary) + (System/exit 0)) + + (let [config-file (str (System/getProperty "user.home") + "/.config/habitual/habitual") + lines (-> config-file slurp str/split-lines) + prog (:prog options) + jobs (for [line lines + :let [line (str/trim line)] + :when (and (seq line) (not (str/starts-with? line "#"))) + :let [[schedule name] (str/split line #"\|" 2) + cron (parse-cron (str/trim schedule))]] + (run-job! (str/trim name) cron prog))] + (println (format "[INFO] Loaded %d job(s), running..." (count (doall jobs)))) + ;; Block forever — futures run the jobs + @(promise)))) + +(apply -main *command-line-args*) + diff --git a/khal_notifier/rewrite_in_bb/khal_notifier.bb b/khal_notifier/rewrite_in_bb/khal_notifier.bb @@ -0,0 +1,238 @@ +#!/usr/bin/env bb + +(ns khal-notifier + (:require [clojure.string :as str] + [babashka.process :refer [shell]] + [babashka.fs :as fs])) + +(defn parse-duration [s] + "Parse duration string like '15m', '1h', '30s' into seconds." + (let [m (re-matches #"(\d+)([smh])" s)] + (if m + (let [value (parse-long (second m)) + unit (nth m 2)] + (case unit + "s" value + "m" (* value 60) + "h" (* value 3600))) + (throw (ex-info (str "Invalid duration format: " s) {:input s}))))) + +(defn run-cmd [cmd args & {:keys [timeout] :or {timeout 30000}}] + "Run a command with timeout. Returns {:status :ok, :out s, :code n} or {:status :error, :reason r}." + (try + (if-not (fs/which cmd) + {:status :error :reason :enoent} + (let [result (apply shell {:out :string :err :string :continue true :timeout timeout} + cmd args)] + {:status :ok :out (:out result) :code (:exit result)})) + (catch java.util.concurrent.TimeoutException _ + {:status :error :reason :timeout}) + (catch Exception e + {:status :error :reason e}))) + +(defn sync-vdirsyncer [] + "Sync calendars with vdirsyncer. Returns true on success." + (println "[INFO] Syncing calendars with vdirsyncer") + (let [result (run-cmd "vdirsyncer" ["sync"] :timeout 60000)] + (cond + (and (= :ok (:status result)) + (zero? (:code result))) + (do (println "[INFO] vdirsyncer sync complete") + true) + + (= :ok (:status result)) + (do (println (format "[WARN] vdirsyncer exited with code %d: %s" + (:code result) + (str/trim (or (:out result) "")))) + false) + + (= :enoent (:reason result)) + (do (println "[ERROR] vdirsyncer not found") + false) + + (= :timeout (:reason result)) + (do (println "[ERROR] vdirsyncer timed out after 60 seconds") + false) + + :else + (do (println (format "[ERROR] vdirsyncer failed: %s" + (ex-message (:reason result)))) + false)))) + +(defn get-upcoming-events [lookahead] + "Query khal for upcoming events. Returns vector of event maps." + (println (format "[INFO] Checking khal for events in the next %s..." lookahead)) + (let [result (run-cmd "khal" ["list" "now" lookahead "--format" "{start-time}|{end-time}|{title}|{organizer}"])] + (cond + (and (= :ok (:status result)) + (zero? (:code result))) + (let [lines (->> (:out result) + str/split-lines + (map str/trim) + (remove str/blank?) + (filter #(str/includes? % "|"))) + events (mapv (fn [line] + (let [parts (str/split line #"\|" 4) + cnt (count parts)] + (cond + (= cnt 4) + {:start-time (str/trim (nth parts 0)) + :end-time (str/trim (nth parts 1)) + :title (str/trim (nth parts 2)) + :organizer (str/trim (nth parts 3))} + + (= cnt 3) + {:start-time (str/trim (nth parts 0)) + :end-time (str/trim (nth parts 1)) + :title (str/trim (nth parts 2)) + :organizer ""} + + (= cnt 2) + {:start-time (str/trim (nth parts 0)) + :end-time "" + :title (str/trim (nth parts 1)) + :organizer ""} + + :else + {:start-time "" + :end-time "" + :title (str/trim (nth parts 0)) + :organizer ""}))) + lines) + events (remove #(str/blank? (:title %)) events)] + (println (format "[INFO] Found %d upcoming event(s)." (count events))) + events) + + (= :ok (:status result)) + (do (println (format "[WARN] khal exited with code %d: %s" + (:code result) + (str/trim (or (:out result) "")))) + []) + + (= :enoent (:reason result)) + (do (println "[ERROR] khal not found. Is it installed and on PATH?") + []) + + (= :timeout (:reason result)) + (do (println "[ERROR] khal timed out.") + []) + + :else + (do (println "[ERROR] khal execution failed.") + [])))) + +(defn now-time [] + "Get current time as HH:MM string." + (-> (shell {:out :string :continue true} "date" "+%H:%M") + :out + str/trim)) + +(defn filter-events [events] + "Filter out events that have already started." + (let [now (now-time)] + (remove #(and (not (str/blank? (:start-time %))) + (<= (compare (:start-time %) now) 0)) + events))) + +(defn format-event [event] + "Format an event for notification body." + (let [time-str (let [{:keys [start-time end-time]} event] + (cond + (and (str/blank? start-time) (str/blank? end-time)) + "All day" + (str/blank? end-time) + start-time + :else + (str start-time " - " end-time)))] + (if (and (contains? event :organizer) + (not (str/blank? (:organizer event)))) + (str time-str "\nOrganizer: " (:organizer event)) + time-str))) + +(defn send-notification [summary body urgency] + "Send a desktop notification using notify-send." + (println (format "[INFO] Notifying: %s — %s" summary body)) + (let [result (run-cmd "notify-send" + ["--app-name" "khal_notifier" + "--urgency" urgency + summary + body])] + (cond + (and (= :ok (:status result)) + (zero? (:code result))) + true + + (= :ok (:status result)) + (do (println (format "[ERROR] notify-send failed (code %d): %s" + (:code result) + (str/trim (or (:out result) "")))) + false) + + (= :enoent (:reason result)) + (do (println "[ERROR] notify-send not found") + false) + + :else + (do (println "[ERROR] notify-send failed") + false)))) + +(defn run-once [lookahead urgency skip-sync] + "Run a single check cycle." + (when-not skip-sync + (when-not (sync-vdirsyncer) + (println "[WARN] Sync failed — continuing with local calendar data"))) + + (let [events (get-upcoming-events lookahead) + filtered (filter-events events)] + (if (empty? filtered) + (println "[INFO] No upcoming events") + (doseq [event filtered] + (let [summary (:title event) + body (format-event event)] + (send-notification summary body urgency)))))) + +(defn run-with-refresh [seconds lookahead urgency skip-sync] + "Run continuously, checking every N seconds." + (println (format "[INFO] Running in refresh mode: checking every %d second(s)" seconds)) + (loop [iteration 0] + (when (pos? iteration) + (println (format "[INFO] Waiting %d second(s) before next refresh" seconds)) + (Thread/sleep (* seconds 1000))) + (println (format "[INFO] Run %d" (inc iteration))) + (run-once lookahead urgency skip-sync) + (recur (inc iteration)))) + +(defn print-usage [] + (println "Usage: khal_notifier [options]") + (println) + (println "Options:") + (println " --lookahead TIME How far ahead to check (default: 15m)") + (println " --urgency LEVEL Notification urgency (default: critical)") + (println " --skip-sync Skip vdirsyncer sync") + (println " --refresh SECONDS Run continuously, re-checking every N seconds (default: 300)") + (println " --help Show this help")) + +(defn parse-args [args] + (loop [pairs (seq args) + acc {}] + (if (empty? pairs) + acc + (let [k (first pairs) + v (second pairs)] + (recur (nnext pairs) + (if (nil? v) + (assoc acc k true) + (assoc acc k v))))))) + +(defn -main [& args] + (let [opts (parse-args args)] + (if (get opts "--help") + (do (print-usage) + (System/exit 0)) + (let [lookahead (get opts "--lookahead" "15m") + urgency (get opts "--urgency" "critical") + skip-sync (contains? opts "--skip-sync") + refresh (parse-long (get opts "--refresh" "300"))] + (run-with-refresh refresh lookahead urgency skip-sync))))) + +(apply -main *command-line-args*) diff --git a/lastfm-csv-export/BUILD b/lastfm-csv-export/BUILD @@ -0,0 +1,7 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "lastfm-csv-export", + deps = all_crate_deps(), +) diff --git a/lastfm-csv-export/Cargo.toml b/lastfm-csv-export/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lastfm-csv-export" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +reqwest = { version = "0.13", features = ["blocking", "json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +csv = "1.4" diff --git a/lastfm-csv-export/src/main.rs b/lastfm-csv-export/src/main.rs @@ -0,0 +1,190 @@ +use clap::Parser; +use csv::Writer; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fs::File; + +const TRACKS_PER_PAGE: u32 = 200; +const API_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; + +#[derive(Parser, Debug)] +#[command(name = "lastfm-csv-export")] +#[command(about = "Export Last.fm scrobbles to a .csv file", long_about = None)] +struct Args { + /// Last.fm username + #[arg(short, long)] + username: String, + + /// Last.fm API key + #[arg(short, long)] + api_key: String, + + /// Output CSV file path + #[arg(short, long, default_value = "scrobbles.csv")] + output: String, + + /// Start timestamp (Unix timestamp) + #[arg(long)] + from: Option<u64>, + + /// End timestamp (Unix timestamp) + #[arg(long)] + to: Option<u64>, + + /// Maximum number of pages to fetch (200 tracks per page) + #[arg(short, long)] + limit: Option<u32>, +} + +#[derive(Debug, Deserialize)] +struct LastFmResponse { + recenttracks: RecentTracks, +} + +#[derive(Debug, Deserialize)] +struct RecentTracks { + track: Vec<Track>, + #[serde(rename = "@attr")] + attr: TrackAttributes, +} + +#[derive(Debug, Deserialize)] +struct TrackAttributes { + total: String, + #[serde(rename = "totalPages")] + total_pages: String, +} + +#[derive(Debug, Deserialize)] +struct Track { + artist: ArtistInfo, + album: AlbumInfo, + name: String, + date: Option<DateInfo>, +} + +#[derive(Debug, Deserialize)] +struct ArtistInfo { + #[serde(rename = "#text")] + text: String, +} + +#[derive(Debug, Deserialize)] +struct AlbumInfo { + #[serde(rename = "#text")] + text: String, +} + +#[derive(Debug, Deserialize)] +struct DateInfo { + #[serde(rename = "#text")] + text: String, +} + +#[derive(Debug, Serialize)] +struct CsvRecord { + artist: String, + album: String, + track: String, + date: String, +} + +fn main() -> Result<(), Box<dyn Error>> { + let args = Args::parse(); + + println!("Fetching scrobbles for user: {}", args.username); + + let client = Client::new(); + let tracks = fetch_all_tracks(&client, &args)?; + + println!("Writing {} tracks to {}", tracks.len(), args.output); + write_csv(&args.output, &tracks)?; + + println!("Done!"); + Ok(()) +} + +fn fetch_all_tracks(client: &Client, args: &Args) -> Result<Vec<Track>, Box<dyn Error>> { + let mut all_tracks = Vec::new(); + + // First request to get total pages + let first_response = fetch_page(client, args, 1)?; + let total_pages: u32 = first_response.recenttracks.attr.total_pages.parse()?; + let total_tracks: u32 = first_response.recenttracks.attr.total.parse()?; + + println!("Total tracks: {}", total_tracks); + println!("Total pages: {}", total_pages); + + // Add tracks from first page + all_tracks.extend(first_response.recenttracks.track); + + // Determine how many pages to fetch + let max_page = args + .limit + .map_or(total_pages, |limit| limit.min(total_pages)); + + // Fetch remaining pages + for page in 2..=max_page { + println!("Fetching page {}/{}", page, max_page); + let response = fetch_page(client, args, page)?; + all_tracks.extend(response.recenttracks.track); + } + + Ok(all_tracks) +} + +fn fetch_page(client: &Client, args: &Args, page: u32) -> Result<LastFmResponse, Box<dyn Error>> { + let mut url = format!( + "{}?method=user.getrecenttracks&user={}&api_key={}&format=json&limit={}&page={}", + API_BASE_URL, args.username, args.api_key, TRACKS_PER_PAGE, page + ); + + if let Some(from) = args.from { + url.push_str(&format!("&from={}", from)); + } + + if let Some(to) = args.to { + url.push_str(&format!("&to={}", to)); + } + + let response = client.get(&url).send()?; + + if !response.status().is_success() { + return Err(format!("API request failed with status: {}", response.status()).into()); + } + + Ok(response.json()?) +} + +fn write_csv(path: &str, tracks: &[Track]) -> Result<(), Box<dyn Error>> { + let file = File::create(path)?; + let mut writer = Writer::from_writer(file); + + // Write empty header row as Maloja doesn't expect it + writer.write_record(["", "", "", ""])?; + + for track in tracks { + let date_str = track + .date + .as_ref() + .map(|date| { + // Input format: "29 Sep 2025, 15:32" + date.text.replace(", ", " ") + }) + .unwrap_or_default(); + + let record = CsvRecord { + artist: track.artist.text.clone(), + album: track.album.text.clone(), + track: track.name.clone(), + date: date_str, + }; + + writer.serialize(&record)?; + } + + writer.flush()?; + Ok(()) +} + diff --git a/lazymaster/BUILD b/lazymaster/BUILD @@ -0,0 +1,20 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("@rules_python//python:defs.bzl", "py_binary") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") +load("//bazel:local-deploy.bzl", "local_deploy") + +py_binary( + name = "lazymaster", + srcs = ["lazymaster.py"], + main = "lazymaster.py", + python_version = "PY3", + deps = [ + "@pypi//requests", + ], +) + +local_deploy( + name = "deploy", + srcs = [":lazymaster"], + copy_runfiles = True, +) diff --git a/lazymaster/README.md b/lazymaster/README.md @@ -0,0 +1,16 @@ +# lazymaster +2-pass audio loudness normalization using `ffmpeg` and [loudnorm](https://ffmpeg.org/ffmpeg-filters.html#loudnorm) filter. + +It normalizes audio to **-13 LUFS**, **-1 dBTP**, and **LRA 8**. + +## Usage + +**Analysis:** +```bash +./lazymaster.py input.wav +``` + +**Two-pass normalization:** +```bash +./lazymaster input.wav output.wav +``` diff --git a/lazymaster/lazymaster.py b/lazymaster/lazymaster.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import json +from typing import cast + + +def main(): + if len(sys.argv) < 2: + print("lazymaster input.wav [output.wav]") + sys.exit(1) + + input_file = sys.argv[1] + + # Measure loudness + cmd_measure = [ + "ffmpeg", + "-i", + input_file, + "-af", + "loudnorm=I=-13:TP=-1:LRA=8:print_format=json", + "-f", + "null", + "-", + ] + + result = subprocess.run(cmd_measure, capture_output=True, text=True) + + # Parse output values + try: + stderr_output = result.stderr + json_start = stderr_output.find("{") + json_end = stderr_output.rfind("}") + 1 + if json_start == -1 or json_end == -1: + raise ValueError("json output is missing") + + stats = cast(dict[str, str], json.loads(stderr_output[json_start:json_end])) + + measured_i = stats["input_i"] + measured_tp = stats["input_tp"] + measured_lra = stats["input_lra"] + measured_thresh = stats["input_thresh"] + offset = stats["target_offset"] + except (ValueError, json.JSONDecodeError, KeyError) as e: + print(f"error parsing loudness stats from ffmpeg output {e}") + sys.exit(1) + + print( + f"I={measured_i}, TP={measured_tp}, LRA={measured_lra}, Thresh={measured_thresh}, Offset={offset}" + ) + + # Normalize loudness + if len(sys.argv) > 2: + output_file = sys.argv[2] + cmd_normalize = [ + "ffmpeg", + "-i", + input_file, + "-af", + f"loudnorm=I=-13:TP=-1:LRA=8:measured_I={measured_i}:measured_LRA={measured_lra}:measured_TP={measured_tp}:measured_thresh={measured_thresh}:offset={offset}:linear=true", + "-y", + output_file, + ] + + _ = subprocess.run(cmd_normalize, check=True) + + +if __name__ == "__main__": + main() diff --git a/mack/BUILD b/mack/BUILD @@ -0,0 +1,7 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "mack", + deps = all_crate_deps(), +) diff --git a/mack/Cargo.toml b/mack/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mack" +version = "1.5.0" +edition = "2024" +authors = ["Chris Down <chris@chrisdown.name>"] +description = "An opinionated, fast music organiser." +repository = "https://github.com/cdown/mack" +readme = "README.md" +keywords = ["music", "audio", "organiser", "tagging", "mp3"] +categories = ["command-line-utilities", "filesystem"] +license = "MIT" + +[dependencies] +clap = { version = "4.5.54", features = ["std", "derive", "help"], default-features = false } +regex = "1.12.2" +anyhow = "1.0.100" +lofty = "0.23" +funcfmt = "0.3.0" +cow-utils = "0.1.3" +rayon = "1.11.0" +jwalk = "0.8.1" +libc = "0.2.180" diff --git a/mack/LICENSE b/mack/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018-present Christopher Down + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/mack/README.md b/mack/README.md @@ -0,0 +1,50 @@ +# mack + +> [!NOTE] +> This is a fork of [github.com/cdown/mack](https://github.com/cdown/mack) + +mack is an opinionated, fast music organiser. It enforces: + +- Directory layout +- File name format +- Metadata consistency (e.g., consistent "feat" tagging) +- Format consistency (e.g., ID3 version) +- ...and more! + +## Examples of fixes + +- Moving featured artists from the artist tag to the title +- Enforcing a consistent "feat" format in title tags +- Whitespace normalisation +- Renaming files to format "{label}/{artist}/{album}/{track} {title}", or another + format specified with `--fmt` +- Reading tags `--read-tags` +- Writing tags `--write-tags` +- Adding a cover art `--add-cover` + +## Usage + +See `--help`. An example invocation is: + + % mack --dry-run -o Music . + 01 Pyramid.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/01 Pyramid.mp3 + 02 8films.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/02 8films.mp3 + 03 tobira.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/03 tobira.mp3 + 04 Compass.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/04 Compass.mp3 + 05 strings.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/05 strings.mp3 + +You can see what would be changed first using `--dry-run`. + +## Installation + + cargo install mack + +## Performance + +mid-spec SSD, this means that we only take 0.005 seconds to run over ~3500 +files under most circumstances. + +## Configuration + +If you don't want a particular file to be touched by mack, add `_NO_MACK` as a +substring anywhere in the comment tag. diff --git a/mack/src/config.rs b/mack/src/config.rs @@ -0,0 +1,61 @@ +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[allow(clippy::struct_excessive_bools)] +pub struct Config { + #[arg( + long, + short = 'n', + help = "Don't actually rename or tag files, only display what would happen" + )] + pub dry_run: bool, + + #[arg( + long, + short, + help = "Use a different output directory (by default, it's the same as the input dir)" + )] + pub output_dir: Option<PathBuf>, + + /// The format to apply to files, excluding the extension. + /// + /// Substitutions can be applied inside curly brackets, for example with {artist} to get the + /// track artist. Any formats returning data with "/" will have it transformed to "_". + /// + /// Available formats: + /// + /// TAG: + /// + /// label + /// artist + /// album + /// track (width: 2) + /// title + /// + /// LITERAL: + /// + /// {{ and }} indicate literal brackets. + #[arg( + long, + verbatim_doc_comment, + default_value = "{label}/{artist}/{album}/{track} {title}" + )] + pub fmt: String, + + #[arg(help = "Directories to find music files in.")] + pub paths: Option<Vec<PathBuf>>, + + #[arg(long, short = 'r', help = "Read tags)")] + pub read_tags: bool, + + #[arg(long, short = 'w', help = "Write tags interactively")] + pub write_tags: bool, + + #[arg(long, short = 'c', help = "Add cover art)")] + pub add_cover: bool, + + #[arg(long, short = 'l', help = "Filter for files without a label")] + pub no_label: bool, +} diff --git a/mack/src/main.rs b/mack/src/main.rs @@ -0,0 +1,345 @@ +mod config; + +mod prompt; +mod track; +mod util; + +use anyhow::Result; +use clap::Parser; +use funcfmt::{FormatPieces, ToFormatPieces, fm}; +use jwalk::WalkDir; +use lofty::prelude::*; +use lofty::tag::{Accessor, ItemKey}; +use rayon::prelude::*; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use config::Config; +use track::{Track, get_track}; + +const ALLOWED_EXTS: &[&str] = &["mp3", "flac", "m4a"]; + +fn fix_track(track: &mut Track, dry_run: bool) { + let fix_results = track::fixers::run_fixers(track, dry_run); + match fix_results { + Ok(applied_fixers) => { + if applied_fixers { + print_updated_tags(track); + } + } + Err(err) => eprintln!("cannot fix {}: {:?}", track.path.display(), err), + } +} + +fn print_updated_tags(track: &Track) { + println!( + "{}: updated tags: artist: '{}', album: '{}', title: '{}', label: '{}'", + track.path.display(), + track.tag.artist().unwrap_or(std::borrow::Cow::Borrowed("")), + track.tag.album().unwrap_or(std::borrow::Cow::Borrowed("")), + track.tag.title().unwrap_or(std::borrow::Cow::Borrowed("")), + util::get_label(&track.tag).unwrap_or(std::borrow::Cow::Borrowed("")) + ); +} + +fn print_tags(track: &Track) { + let label = util::get_label(&track.tag).unwrap_or(std::borrow::Cow::Borrowed("Unknown Label")); + + println!( + "Folder: {}\nArtist: {}\nAlbum: {}\nLabel: {}\n", + track + .path + .parent() + .unwrap_or_else(|| Path::new(".")) + .display(), + track + .tag + .artist() + .unwrap_or(std::borrow::Cow::Borrowed("Unknown Artist")), + track + .tag + .album() + .unwrap_or(std::borrow::Cow::Borrowed("Unknown Album")), + label + ); +} + +fn rename_track(track: &Track, fp: &FormatPieces<Track>, output_path: &Path, dry_run: bool) { + let new_path = track::rename::rename_track(track, fp, output_path, dry_run); + + match new_path { + Ok(Some(new_path)) => println!( + "{}: renamed to {}", + track.path.display(), + new_path.display() + ), + Ok(None) => (), + Err(err) => eprintln!("cannot rename {}: {:?}", track.path.display(), err), + } +} + +const ADDITIONAL_ACCEPTED_CHARS: &[char] = &['.', '-', '(', ')', ',']; + +fn clean_part(path_part: &str) -> String { + path_part + .chars() + .map(|c| { + if c.is_alphanumeric() || c.is_whitespace() || ADDITIONAL_ACCEPTED_CHARS.contains(&c) { + c + } else { + '_' + } + }) + .collect() +} + +fn get_format_pieces(tmpl: &str) -> Result<funcfmt::FormatPieces<Track>> { + let formatters = fm!( + "artist" => |t: &Track| Some(clean_part(t.tag.artist().as_deref().unwrap_or("Unknown Artist"))), + "album" => |t: &Track| Some(clean_part(t.tag.album().as_deref().unwrap_or("Unknown Album"))), + "title" => |t: &Track| Some(clean_part(t.tag.title().as_deref().unwrap_or("Unknown Title"))), + "track" => |t: &Track| Some(format!("{:02}", t.tag.track().unwrap_or_default())), + "label" => |t: &Track| Some(clean_part(util::get_label(&t.tag).as_deref().unwrap_or("Unknown Label"))), + ); + + Ok(formatters.to_format_pieces(tmpl)?) +} + +fn run_write_tags(paths: &[PathBuf], no_label: bool) { + let mut by_folder: std::collections::HashMap<PathBuf, Vec<PathBuf>> = + std::collections::HashMap::new(); + for path in paths { + let parent = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + by_folder.entry(parent).or_default().push(path.clone()); + } + + // Sort folders for consistent UX + let mut parents: Vec<_> = by_folder.keys().cloned().collect(); + parents.sort(); + + for parent in parents { + let mut files = by_folder[&parent].clone(); + if files.is_empty() { + continue; + } + + if no_label { + files.retain(|path| { + if let Ok(track) = get_track(path.clone()) { + util::get_label(&track.tag).is_none() + } else { + false + } + }); + } + + if files.is_empty() { + continue; + } + + // Use first file to guess current tags + let first_path = &files[0]; + let first_track = match get_track(first_path.clone()) { + Ok(t) => t, + Err(e) => { + eprintln!("Error reading {}: {e:?}", first_path.display()); + continue; + } + }; + + // Prompt user once for the folder + println!("\nEditing Folder: {}", parent.display()); + let (new_artist, new_album, new_label) = prompt::prompt_tags(&first_track); + + // Apply to all files in folder if values are present via prompt_tags + for path in files { + match get_track(path.clone()) { + Ok(mut track) => { + let mut changed = false; + if let Some(ref a) = new_artist { + track.tag.set_artist(a.clone()); + changed = true; + } + if let Some(ref a) = new_album { + track.tag.set_album(a.clone()); + changed = true; + } + if let Some(ref l) = new_label + && util::get_label(&track.tag).is_none() + { + track.tag.insert_text(ItemKey::Label, l.clone()); + changed = true; + } + + if changed + && let Err(e) = track + .tag + .save_to_path(&track.path, lofty::config::WriteOptions::default()) + { + eprintln!("Error saving {}: {e:?}", track.path.display()); + } + } + Err(e) => eprintln!("Error reading {}: {e:?}", path.display()), + } + } + } +} + +fn run_read_tags(paths: &[PathBuf], no_label: bool) { + let mut seen_dirs = std::collections::HashSet::new(); + for path in paths { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + if seen_dirs.contains(parent) { + continue; + } + if let Ok(track) = get_track(path.clone()) { + if no_label && util::get_label(&track.tag).is_some() { + continue; + } + print_tags(&track); + seen_dirs.insert(parent.to_path_buf()); + } + } +} + +fn run_add_cover(paths: &[PathBuf]) { + paths.par_iter().for_each(|path| { + if let Ok(mut track) = get_track(path.clone()) + && let Err(e) = add_cover_art(&mut track) + { + eprintln!("Error adding cover for {}: {e:?}", path.display()); + } + }); +} + +fn run_fix_rename(cfg: &Config, output_path: &Path, paths: &[PathBuf]) { + let default_fmt = "{label}/{artist}/{album}/{track} {title}"; + let fallback_fmt = "{artist}/{album}/{track} {title}"; + + let fp_default = match get_format_pieces(&cfg.fmt) { + Ok(fp) => fp, + Err(err) => { + eprintln!("fatal: {err}"); + std::process::exit(1); + } + }; + + // Only compile fallback if we are using the default format + let fp_fallback = if cfg.fmt == default_fmt { + Some(match get_format_pieces(fallback_fmt) { + Ok(fp) => fp, + Err(err) => { + eprintln!("fatal: {err}"); + std::process::exit(1); + } + }) + } else { + None + }; + + paths + .par_iter() + .for_each(|path| match get_track(path.clone()) { + Ok(mut track) => { + fix_track(&mut track, cfg.dry_run); + + // Determine format to use + let format_to_use = if let Some(ref fp_fb) = fp_fallback { + if util::get_label(&track.tag).is_some() { + &fp_default + } else { + fp_fb + } + } else { + &fp_default + }; + + rename_track(&track, format_to_use, output_path, cfg.dry_run); + } + Err(err) => eprintln!("error: {}: {err:?}", path.display()), + }); +} + +fn fix_all_tracks(cfg: &Config, base_path: &PathBuf, output_path: &Path) { + let walker = WalkDir::new(base_path).skip_hidden(false); + let paths: Vec<PathBuf> = walker + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_file()) + .map(|e| e.path()) + .filter(|e| { + let ext = e + .extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase(); + ALLOWED_EXTS.iter().any(|a| a == &ext) + }) + .collect(); + + // Use sequential interaction for write_tags, otherwise parallel + if cfg.write_tags { + run_write_tags(&paths, cfg.no_label); + return; + } + + if cfg.read_tags { + run_read_tags(&paths, cfg.no_label); + return; + } + + if cfg.add_cover { + run_add_cover(&paths); + return; + } + + run_fix_rename(cfg, output_path, &paths); +} + +fn add_cover_art(track: &mut Track) -> Result<()> { + // Check for cover.jpg, cover.png, folder.jpg, folder.png in the same directory + let parent = track.path.parent().unwrap_or_else(|| Path::new(".")); + let candidates = ["cover.jpg", "cover.png", "folder.jpg", "folder.png"]; + + let mut cover_path = None; + for c in candidates { + let p = parent.join(c); + if p.exists() { + cover_path = Some(p); + break; + } + } + + if let Some(path) = cover_path { + println!( + "Adding cover art from {} to {}", + path.display(), + track.path.display() + ); + let mut picture = lofty::picture::Picture::from_reader(&mut std::fs::File::open(&path)?)?; + track + .tag + .remove_picture_type(lofty::picture::PictureType::CoverFront); + picture.set_pic_type(lofty::picture::PictureType::CoverFront); + track.tag.push_picture(picture); + track + .tag + .save_to_path(&track.path, lofty::config::WriteOptions::default())?; + } + + Ok(()) +} + +fn main() { + let mut cfg = Config::parse(); + + let paths = cfg.paths.take().unwrap_or_else(|| vec![PathBuf::from(".")]); + + for path in paths { + let output_path = cfg.output_dir.clone().unwrap_or_else(|| path.clone()); + fix_all_tracks(&cfg, &path, &output_path); + } +} diff --git a/mack/src/prompt.rs b/mack/src/prompt.rs @@ -0,0 +1,43 @@ +use lofty::tag::Accessor; +use std::io::{self, Write}; + +use crate::track::Track; + +fn prompt(prompt_text: &str) -> String { + print!("{prompt_text}"); + io::stdout().flush().unwrap(); + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + input.trim().to_string() +} + +pub fn prompt_tags(track: &Track) -> (Option<String>, Option<String>, Option<String>) { + let current_artist = track.tag.artist().unwrap_or(std::borrow::Cow::Borrowed("")); + let new_artist_input = prompt(&format!("Artist [{current_artist}]: ")); + let new_artist = if new_artist_input.is_empty() { + None + } else { + Some(new_artist_input) + }; + + let current_album = track.tag.album().unwrap_or(std::borrow::Cow::Borrowed("")); + let new_album_input = prompt(&format!("Album [{current_album}]: ")); + let new_album = if new_album_input.is_empty() { + None + } else { + Some(new_album_input) + }; + + // Use common label detection logic + let current_label = + crate::util::get_label(&track.tag).unwrap_or(std::borrow::Cow::Borrowed("")); + + let new_label_input = prompt(&format!("Label [{current_label}]: ")); + let new_label = if new_label_input.is_empty() { + None + } else { + Some(new_label_input) + }; + + (new_artist, new_album, new_label) +} diff --git a/mack/src/track/feat.rs b/mack/src/track/feat.rs @@ -0,0 +1,251 @@ +use regex::Regex; + +const FEAT_KEYWORDS: &[&str] = &["feat", "ft", "f.", "featuring"]; + +/// Represents a track's title after extracting featured artists. +#[derive(Debug, PartialEq, Eq)] +pub struct TrackFeat { + pub title: String, + pub featured_artists: Vec<String>, + pub original_title: String, +} + +/// Holds the indices and inner content of a bracketed feature section. +struct BracketedFeat<'a> { + open_idx: usize, + close_idx: usize, + content: &'a str, +} + +/// Extracts featured artist information from a track title. +pub fn extract_feat(title: &str) -> TrackFeat { + if let Some(bf) = find_bracketed_feat(title, FEAT_KEYWORDS) { + let artist_part = remove_keyword_from_content(bf.content, FEAT_KEYWORDS); + let featured_artists = split_artists(artist_part); + let base_title = format!( + "{} {}", + &title[..bf.open_idx].trim_end(), + &title[bf.close_idx + 1..].trim_start() + ) + .trim() + .to_string(); + + return TrackFeat { + title: base_title, + featured_artists, + original_title: title.to_string(), + }; + } + + if let Some(pos) = find_non_bracketed_feat(title) { + let inner = &title[pos..].trim(); + let artist_part = remove_keyword_from_content(inner, FEAT_KEYWORDS); + let featured_artists = split_artists(artist_part); + let base_title = title[..pos].trim_end().to_string(); + return TrackFeat { + title: base_title, + featured_artists, + original_title: title.to_string(), + }; + } + + // No feature found, return as is + TrackFeat { + title: title.to_string(), + featured_artists: Vec::new(), + original_title: title.to_string(), + } +} + +/// Searches for a bracketed section (using '(' or '[') whose inner content, when trimmed +/// and lowercased, starts with one of the specified keywords. If found, returns a `BracketedFeat` +/// containing the opening index, closing index, and the inner content. +fn find_bracketed_feat<'a>(title: &'a str, keywords: &[&str]) -> Option<BracketedFeat<'a>> { + let mut chars = title.char_indices().peekable(); + while let Some((i, ch)) = chars.next() { + if ch == '(' || ch == '[' { + let closing = if ch == '(' { ')' } else { ']' }; + let mut depth = 1; + let mut j = i; + for (k, ch2) in chars.by_ref() { + if ch2 == ch { + depth += 1; + } else if ch2 == closing { + depth -= 1; + if depth == 0 { + j = k; + break; + } + } + } + if depth == 0 { + // Get the inner content (without the brackets). + let content = &title[i + ch.len_utf8()..j]; + if keywords + .iter() + .any(|&kw| content.trim().to_lowercase().starts_with(kw)) + { + return Some(BracketedFeat { + open_idx: i, + close_idx: j, + content, + }); + } + } + } + } + None +} + +/// Searches for a non-bracketed occurrence of any feature keyword (case–insensitively) +/// in the title. Returns the earliest index if found. +fn find_non_bracketed_feat(title: &str) -> Option<usize> { + static FEAT_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| { + let escaped_keywords: Vec<String> = + FEAT_KEYWORDS.iter().map(|&s| regex::escape(s)).collect(); + + let pattern = format!(r"(?i)(?:^|\W)({})(:?$|\W)", escaped_keywords.join("|")); + Regex::new(&pattern).expect("BUG: Invalid feat regex") + }); + + FEAT_RE + .captures(title) + .and_then(|caps| caps.get(1)) + .map(|m| m.start()) +} + +/// Given some content (either from inside a bracket or not) that starts with a feature keyword, +/// remove that keyword (choosing the longest matching one) and any immediately following period. +fn remove_keyword_from_content<'a>(content: &'a str, keywords: &[&str]) -> &'a str { + let trimmed = content.trim(); + let lower = trimmed.to_lowercase(); + if let Some(&kw) = keywords + .iter() + .filter(|&&k| lower.starts_with(k)) + .max_by_key(|k| k.len()) + { + let after = trimmed.get(kw.len()..).unwrap_or("").trim_start(); + if let Some(rest) = after.strip_prefix('.') { + rest.trim_start() + } else { + after + } + } else { + trimmed + } +} + +fn split_artists(artists_str: &str) -> Vec<String> { + let feat_artists: Vec<String> = artists_str + .split(',') + .flat_map(|s| s.split(" and ").flat_map(|s| s.split(" & "))) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + feat_artists +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_feat_no_feat() { + let given = "A plain title".to_owned(); + let expected = TrackFeat { + title: given.clone(), + featured_artists: Vec::new(), + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_no_feat_in_word() { + let given = "Lift Method".to_owned(); + let expected = TrackFeat { + title: given.clone(), + featured_artists: Vec::new(), + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_with_feat_single() { + let given = "A plain title feat. Foo Bar".to_owned(); + let expected = TrackFeat { + title: "A plain title".to_owned(), + featured_artists: vec!["Foo Bar".to_owned()], + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_with_feat_double() { + let given = "A plain title Ft. Foo Bar and Baz Qux".to_owned(); + let expected = TrackFeat { + title: "A plain title".to_owned(), + featured_artists: vec!["Foo Bar".to_owned(), "Baz Qux".to_owned()], + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_with_feat_as_f() { + let given = "A plain title f. Foo Bar and Baz Qux".to_owned(); + let expected = TrackFeat { + title: "A plain title".to_owned(), + featured_artists: vec!["Foo Bar".to_owned(), "Baz Qux".to_owned()], + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_with_feat_many() { + let given = "A plain title feat Foo Bar, Baz Qux, and Wibble Wobble".to_owned(); + let expected = TrackFeat { + title: "A plain title".to_owned(), + featured_artists: vec![ + "Foo Bar".to_owned(), + "Baz Qux".to_owned(), + "Wibble Wobble".to_owned(), + ], + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_with_surrounding_whitespace() { + let given = "A plain title feat Foo Bar, Baz Qux, and Wibble Wobble ".to_owned(); + let expected = TrackFeat { + title: "A plain title".to_owned(), + featured_artists: vec![ + "Foo Bar".to_owned(), + "Baz Qux".to_owned(), + "Wibble Wobble".to_owned(), + ], + original_title: given.clone(), + }; + assert_eq!(extract_feat(&given), expected); + } + + #[test] + fn test_extract_feat_nested_parentheses() { + let given = "比較大的大提琴 [Featuring Lara Veronin (梁心頤) & Gary Yang (楊瑞代)]"; + let expected = TrackFeat { + title: "比較大的大提琴".to_string(), + featured_artists: vec![ + "Lara Veronin (梁心頤)".to_string(), + "Gary Yang (楊瑞代)".to_string(), + ], + original_title: given.to_string(), + }; + assert_eq!(extract_feat(given), expected); + } +} diff --git a/mack/src/track/fixers.rs b/mack/src/track/fixers.rs @@ -0,0 +1,205 @@ +use crate::track::Track; +use crate::track::feat::{TrackFeat, extract_feat}; +use anyhow::{Result, bail}; +use cow_utils::CowUtils; +use lofty::config::WriteOptions; +use lofty::prelude::*; +use lofty::tag::{ItemKey, Tag}; + +use regex::Regex; + +static MULTI_WS_RE: std::sync::LazyLock<Regex> = + std::sync::LazyLock::new(|| Regex::new(r"[ \t]+").expect("BUG: Invalid regex")); + +pub fn run_fixers(track: &mut Track, dry_run: bool) -> Result<bool> { + let tags = &mut track.tag; + + fixer_is_blacklisted(tags)?; + + let new_title = fix_title(tags.title().as_deref(), tags.artist().as_deref()); + let new_artist = fix_artist(tags.artist().as_deref()); + let new_album = fix_album(tags.album().as_deref()); + let mut changed = false; + + if let Some(new_artist) = new_artist { + changed = true; + tags.set_artist(new_artist); + } + if let Some(new_title) = new_title { + changed = true; + tags.set_title(new_title); + } + if let Some(new_album) = new_album { + changed = true; + tags.set_album(new_album); + } + + if !dry_run && changed { + tags.save_to_path(&track.path, WriteOptions::default())?; + } + + Ok(changed) +} + +// False positive: https://github.com/rust-lang/rust-clippy/issues/12444 +#[allow(clippy::assigning_clones)] +fn normalise_field(field: &str) -> String { + let normalized = MULTI_WS_RE.replace_all(field, " ").to_string(); + let trimmed = normalized.trim().to_owned(); + trimmed + .cow_replace('[', "(") + .cow_replace(']', ")") + .cow_replace('…', "...") + .cow_replace('“', "\"") + .cow_replace('”', "\"") + .cow_replace('‘', "'") + .cow_replace('’', "'") + .to_string() +} + +fn fix_artist(old_artist: Option<&str>) -> Option<String> { + let field = normalise_field(old_artist.unwrap_or_default()); + let artist = extract_feat(&field); + if artist.title == artist.original_title { + None + } else { + Some(artist.title) + } +} + +fn fix_album(old_album: Option<&str>) -> Option<String> { + let old_album = old_album?; + let new_album = normalise_field(old_album); + + if new_album == old_album { + None + } else { + Some(new_album) + } +} + +fn fix_title(old_title: Option<&str>, old_artist: Option<&str>) -> Option<String> { + let old_title = old_title?; + let title_feat = extract_feat(old_title); + let artist_feat = extract_feat(old_artist.unwrap_or_default()); + let new_title = make_title(&title_feat, artist_feat); + + if new_title == title_feat.original_title { + None + } else { + Some(new_title) + } +} + +fn make_title(title: &TrackFeat, artist: TrackFeat) -> String { + let mut featured_artists = title.featured_artists.clone(); + featured_artists.extend(artist.featured_artists); + + let mut new_title = title.title.clone(); + if !featured_artists.is_empty() { + let feat_artists_string = make_feat_string(&featured_artists); + let feat_string = format!(" (feat. {feat_artists_string})"); + new_title.push_str(&feat_string); + } + + normalise_field(&new_title) +} + +fn make_feat_string(featured_artists: &[String]) -> String { + match featured_artists.len() { + 0 => String::new(), + 1 => featured_artists[0].clone(), + 2 => format!("{} and {}", featured_artists[0], featured_artists[1]), + _ => { + let head = &featured_artists[..featured_artists.len() - 1]; + let last = &featured_artists[featured_artists.len() - 1]; + format!("{}, and {}", head.join(", "), last) + } + } +} + +fn fixer_is_blacklisted(tags: &Tag) -> Result<()> { + for item in tags.items() { + if item.key() == ItemKey::Comment + && let Some(text) = item.value().text() + && text.contains("_NO_MACK") + { + bail!("Comment contains _NO_MACK"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fix_artist_no_feat() { + let given = "Foo Bar"; + let expected = None; + assert_eq!(fix_artist(Some(given)), expected); + } + + #[test] + fn test_fix_artist_with_feat() { + let given = "Foo Bar (feat. Baz Qux)"; + let expected = Some("Foo Bar".to_owned()); + assert_eq!(fix_artist(Some(given)), expected); + } + + #[test] + fn test_fix_title_no_title_feat_no_artist_feat() { + let given_title = "Foo Bar"; + let given_artist = "Baz Qux"; + let expected = None; + assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); + } + + #[test] + fn test_fix_title_with_title_feat_no_artist_feat() { + let given_title = "Foo Bar (feat. Wibble Wobble)"; + let given_artist = "Baz Qux"; + let expected = None; + assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); + } + + #[test] + fn test_fix_title_with_title_feat_no_artist_feat_and_brackets() { + let given_title = "Foo Bar (feat. Wibble Wobble) [Richard Stallman mix]"; + let given_artist = "Baz Qux"; + let expected = Some("Foo Bar (Richard Stallman mix) (feat. Wibble Wobble)".to_owned()); + assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); + } + + #[test] + fn test_fix_title_no_title_feat_with_artist_feat() { + let given_title = "Foo Bar"; + let given_artist = "Baz Qux feat. Fizz Buzz"; + let expected = Some("Foo Bar (feat. Fizz Buzz)".to_owned()); + assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); + } + + #[test] + fn test_fix_title_with_title_feat_and_artist_feat() { + let given_title = "Foo Bar (feat. Wibble Wobble)"; + let given_artist = "Baz Qux feat. Fizz Buzz"; + let expected = Some("Foo Bar (feat. Wibble Wobble and Fizz Buzz)".to_owned()); + assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); + } + + #[test] + fn test_fix_title_with_title_feat_smart_quotes() { + let given_title = "Foo ‘Bar’ (feat. Wibble “Wabble” Wobble)"; + let given_artist = "Baz Qux"; + let expected = Some("Foo 'Bar' (feat. Wibble \"Wabble\" Wobble)".to_owned()); + assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); + } + + #[test] + fn test_fix_whitespace() { + let given = " Foo Bar [feat. Baz Qux ] "; + let expected = Some("Foo Bar (feat. Baz Qux)".to_owned()); + assert_eq!(fix_title(Some(given), None), expected); + } +} diff --git a/mack/src/track/loader.rs b/mack/src/track/loader.rs @@ -0,0 +1,16 @@ +use crate::track::Track; +use anyhow::Result; +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::Tag; +use std::path::PathBuf; + +pub fn get_track(path: PathBuf) -> Result<Track> { + let tagged_file = Probe::open(&path)?.read()?; + let tag = tagged_file + .primary_tag() + .cloned() + .or_else(|| tagged_file.first_tag().cloned()) + .unwrap_or_else(|| Tag::new(tagged_file.file_type().primary_tag_type())); + Ok(Track { path, tag }) +} diff --git a/mack/src/track/mod.rs b/mack/src/track/mod.rs @@ -0,0 +1,15 @@ +pub mod feat; +pub mod fixers; +pub mod loader; +pub mod rename; + +pub use loader::get_track; + +use lofty::tag::Tag; +use std::path::PathBuf; + +/// Represents a music track with its file path and associated ID3 tag. +pub struct Track { + pub path: PathBuf, + pub tag: Tag, +} diff --git a/mack/src/track/rename.rs b/mack/src/track/rename.rs @@ -0,0 +1,106 @@ +use crate::track::Track; +use anyhow::{Context, Result}; +use funcfmt::{FormatPieces, Render}; + +use regex::Regex; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[cfg(target_family = "unix")] +use libc::EXDEV as xdev_err; + +fn rename_creating_dirs(from: &PathBuf, to: &PathBuf) -> Result<()> { + fs::create_dir_all(to.parent().context("Refusing to move to FS root")?)?; + + // Trying to rename cross device? Just copy and unlink the old one + if let Err(err) = fs::rename(from, to) { + if err.raw_os_error() == Some(xdev_err) { + fs::copy(from, to)?; + fs::remove_file(from)?; + } else { + Err(err)?; + } + } + Ok(()) +} + +/// `String::truncate` will panic if not at a char boundary +fn safe_truncate(s: &mut String, max_chars: usize) { + if let Some((idx, _)) = s.char_indices().nth(max_chars) { + s.truncate(idx); + } +} + +// Arbitrary limit on path part without extension to try to avoid brushing against PATH_MAX. We +// can't just check PATH_MAX and similar, because we also want to avoid issues when copying +// elsewhere later. +static MULTI_DOT_RE: std::sync::LazyLock<Regex> = + std::sync::LazyLock::new(|| Regex::new(r"\.\.+").expect("BUG: Invalid regex")); +const MAX_PATH_PART_LEN: usize = 64; +fn normalise_dirs(path_part: String) -> PathBuf { + let partial = PathBuf::from(path_part); + partial + .components() + .map(|c| { + let mut s = c + .as_os_str() + .to_os_string() + .into_string() + .expect("invalid path"); + safe_truncate(&mut s, MAX_PATH_PART_LEN); + + // Illegal characters on Linux are only / (handled by components) and null byte (handled by Rust string types) + s = s.replace('/', "_"); + + // Trim leading/trailing whitespace, which can be problematic on some filesystems. + s = s.trim().to_string(); + + // exfat normalises this and it confuses adb-sync and other tooling + s = MULTI_DOT_RE.replace_all(&s, ".").to_string(); + + // Disallow leading dots to prevent creating hidden files/directories. + let s = s.trim_start_matches('.').to_string(); + + // If the component is now empty (e.g. it was just "."), use a placeholder. + if s.is_empty() { "_".to_string() } else { s } + }) + .collect() +} + +fn add_extension(path: PathBuf, ext: impl AsRef<OsStr>) -> PathBuf { + let mut os_string: OsString = path.into(); + os_string.push("."); + os_string.push(ext.as_ref()); + os_string.into() +} + +pub fn rename_track( + track: &Track, + fp: &FormatPieces<Track>, + output_path: &Path, + dry_run: bool, +) -> Result<Option<PathBuf>> { + let mut new_path = output_path.to_path_buf(); + let partial = normalise_dirs(fp.render(track)?); + new_path.push(partial); + + // We might have truncated and have a dot elsewhere, so we can't use set_extension + new_path = add_extension( + new_path, + track + .path + .extension() + .context("ext required in walkbuilder, but missing")?, + ); + + if new_path == track.path { + return Ok(None); + } + + if !dry_run { + rename_creating_dirs(&track.path, &new_path)?; + } + + Ok(Some(new_path)) +} diff --git a/mack/src/util.rs b/mack/src/util.rs @@ -0,0 +1,6 @@ +use lofty::tag::{ItemKey, Tag}; +use std::borrow::Cow; + +pub fn get_label(tag: &Tag) -> Option<Cow<'_, str>> { + tag.get_string(ItemKey::Label).map(Cow::Borrowed) +} diff --git a/mack/todo.txt b/mack/todo.txt @@ -0,0 +1 @@ +- if label exists, it doesnt change when using `-w` (introduced in last commit) diff --git a/matchakey/BUILD b/matchakey/BUILD @@ -0,0 +1,33 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app", "rust_lib") +load("@rules_rust//rust:defs.bzl", "rust_test") + +rust_lib( + name = "lib", + crate_name = "matchakey", + deps = all_crate_deps(), +) + +rust_app( + name = "matchakey", + srcs = ["src/main.rs"], + deps = [":lib"] + all_crate_deps(), +) + +rust_test( + name = "matchakey_integration_test", + srcs = [ + "tests/integration_tests.rs", + "tests/utils_tests.rs", + ], + crate_root = "tests/integration_tests.rs", + edition = "2024", + rustc_flags = [ + "-Ctarget-cpu=native", + "-Clink-arg=-fuse-ld=mold", + ], + visibility = ["//visibility:public"], + deps = [ + ":lib", + ] + all_crate_deps(), +) diff --git a/matchakey/Cargo.toml b/matchakey/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "matchakey" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.101" +clap = { version = "4.5.57", features = ["derive"] } +colored = "3.1.1" +lofty = "0.23.1" +rand = "0.10.0" +rayon = "1.10" +regex = "1.12.3" +rusqlite = "0.38.0" +tracing = "0.1.44" +tracing-subscriber = "0.3.18" +walkdir = "2.5" diff --git a/matchakey/README.md b/matchakey/README.md @@ -0,0 +1,76 @@ +# match-a-key + +A tool that finds compatible tracks based on `Key` and `BPM` tags which makes it a great companion for digging, also compatible with Mixxx. + +## Features + +* Find tracks with compatible musical keys using Camelot/Harmonic mixing rules +* Filter tracks by BPM tolerance (drift) +* Support for local music library scanning +* Integration with Mixxx database for direct track queries +* Verbose output for debugging + +## Building + +To build the project, run: + +```sh +cargo build --release +``` + +Or if using Bazel: + +```sh +bazel build //:matchakey +``` + +## Usage + +There are two ways to use the tool: + +1. Search for compatible tracks in local directories: +```sh +./target/release/matchakey <REFERENCE_TRACK> [OPTIONS] <SOURCE_PATH>... +``` + +2. Query compatible tracks from Mixxx database: +```sh +# Using default Mixxx database path (~/.mixxx/mixxxdb.sqlite) +./target/release/matchakey <REFERENCE_TRACK> --mixxx [OPTIONS] + +# Using custom Mixxx database path +./target/release/matchakey <REFERENCE_TRACK> --mixxx-path <MIXXX_DATABASE_PATH> [OPTIONS] +``` + +### Arguments + +* `<REFERENCE_TRACK>`: The path to the reference audio file. +* `<SOURCE_PATH>...`: One or more directories to search for compatible tracks. Required unless `--mixxx` is specified. + +### Options + +* `--drift <DRIFT>`: BPM drift tolerance. If specified, only tracks with a BPM within `DRIFT` of the reference track will be shown. +* `--mixxx`: Use Mixxx database (defaults to ~/.mixxx/mixxxdb.sqlite). +* `--mixxx-path <MIXXX_PATH>`: Custom path to Mixxx database (implies --mixxx if provided). +* `--mixxx-db-size <MIXXX_DB_SIZE>`: Number of tracks to fetch from Mixxx database for shuffling (default: 1000). +* `--limit <LIMIT>`: Limit number of tracks to display from Mixxx database (default: 10). +* `-v`, `--verbose`: Enable verbose output, showing the number of files found. +* `-h`, `--help`: Print help information. +* `-V`, `--version`: Print version information. + +### Example + +```sh +./target/release/matchakey /path/to/my/track.mp3 /path/to/my/music/library +``` + +```sh +# Using default Mixxx database path +./target/release/matchakey /path/to/my/track.mp3 --mixxx --drift 2.0 + +# Using custom Mixxx database path +./target/release/matchakey /path/to/my/track.mp3 --mixxx-path ~/.mixxx/mixxxdb.sqlite --drift 2.0 + +# Using custom database fetch size and display limit +./target/release/matchakey /path/to/my/track.mp3 --mixxx --mixxx-db-size 2000 --limit 20 --drift 2.0 +``` diff --git a/matchakey/src/db.rs b/matchakey/src/db.rs @@ -0,0 +1,106 @@ +use anyhow::Result; +use rusqlite::{Connection, Row}; +use std::fmt::Write; +use std::path::PathBuf; + +use crate::track::TrackInfo; +use crate::key; +use crate::drift; + +/// Find and process tracks from a Mixxx `SQLite` database +/// +/// # Arguments +/// * `db_path` - Path to the Mixxx `SQLite` database file +/// * `mixxx_limit` - Maximum number of tracks to fetch (uses default of 1000 if None) +/// * `drift` - BPM drift tolerance for filtering tracks (uses default of 3.0 if None) +/// * `ref_track` - Reference track to compare against +/// +/// # Errors +/// Returns an error if the database cannot be opened or queried +pub fn find_tracks_from_mixxx( + db_path: &PathBuf, + db_fetch_limit: Option<u32>, + drift: Option<f32>, + ref_track: &TrackInfo, +) -> Result<Vec<TrackInfo>> { + let conn = Connection::open(db_path)?; + let limit = db_fetch_limit.unwrap_or(1000); + + let mut query = String::from( + "SELECT t1.artist, t1.title, t1.album, t1.year, t1.bpm, t1.[key], t1.datetime_added + FROM library t1 + WHERE (t1.mixxx_deleted IS NULL OR t1.mixxx_deleted = 0)", + ); + + // Apply drift filtering with default value of 3.0 + query.push_str(" AND t1.bpm IS NOT NULL"); + if ref_track.bpm > 0.0 { + let drift_val = drift.unwrap_or(3.0); + let _ = write!( + query, + " AND ABS(t1.bpm - {}) <= {}", + ref_track.bpm, drift_val + ); + } + + let _ = write!(query, " ORDER BY t1.id LIMIT {limit}"); + + let mut stmt = conn.prepare(&query)?; + + let track_iter = stmt.query_map([], |row: &Row| { + Ok(( + row.get::<usize, Option<String>>(0)?, // artist + row.get::<usize, Option<String>>(1)?, // title + row.get::<usize, Option<String>>(2)?, // album + row.get::<usize, Option<String>>(3)?, // year + row.get::<usize, Option<f32>>(4)?, // bpm + row.get::<usize, Option<String>>(5)?, // key + row.get::<usize, Option<String>>(6)?, // datetime_added + )) + })?; + + let mut results: Vec<TrackInfo> = Vec::new(); + + for track_result in track_iter { + match track_result { + Ok((artist, title, _, _, bpm_opt, key_opt, datetime_added_opt)) => { + // Process the track data + let bpm: f32 = bpm_opt.unwrap_or(0.0); + + let key_raw = key_opt.unwrap_or_default(); + let key = key::Key::from_string(&key_raw); + + // Check key compatibility using the drift module + let temp_track_info = TrackInfo { + bpm, + key: key.clone(), + key_raw: key_raw.clone(), + title: title.clone(), + artist: artist.clone(), + datetime_added: datetime_added_opt.clone(), + }; + + if !drift::is_key_compatible(&temp_track_info, ref_track) { + continue; // Skip this track if keys are not compatible + } + + // Create TrackInfo object + let track_info = TrackInfo { + bpm, + key, + key_raw, + title, + artist, + datetime_added: datetime_added_opt, + }; + + results.push(track_info); + } + Err(e) => { + tracing::warn!("Error reading track from database: {}", e); + } + } + } + + Ok(results) +} diff --git a/matchakey/src/drift.rs b/matchakey/src/drift.rs @@ -0,0 +1,65 @@ +use crate::track::TrackInfo; + +/// Check if a track's BPM is within the drift tolerance of a reference track +/// +/// # Arguments +/// * `track` - The track to check +/// * `ref_track` - The reference track to compare against +/// * `drift` - The BPM drift tolerance +/// +/// # Returns +/// True if the track's BPM is within the drift tolerance, false otherwise +pub fn is_within_drift_tolerance(track: &TrackInfo, ref_track: &TrackInfo, drift: f32) -> bool { + // If reference has BPM but target doesn't, skip target only if drift is specified. + if ref_track.bpm > 0.0 && track.bpm == 0.0 { + return false; + } + + // Filter by BPM if drift is specified + if ref_track.bpm > 0.0 && track.bpm > 0.0 && (track.bpm - ref_track.bpm).abs() > drift { + return false; + } + + true +} + +/// Check if a track's key is compatible with a reference track's key +/// +/// # Arguments +/// * `track` - The track to check +/// * `ref_track` - The reference track to compare against +/// +/// # Returns +/// True if the track's key is compatible with the reference track's key, false otherwise +pub fn is_key_compatible(track: &TrackInfo, ref_track: &TrackInfo) -> bool { + if let Some(ref_key) = &ref_track.key { + if let Some(track_key) = &track.key { + return ref_key.is_compatible(track_key); + } + // If target has no key, return false + return false; + } + // If reference has no key, consider it compatible + true +} + +/// Check if a track should be filtered based on BPM drift and key compatibility +/// +/// # Arguments +/// * `track` - The track to check +/// * `ref_track` - The reference track to compare against +/// * `drift_opt` - Optional BPM drift tolerance (if None, no drift filtering is applied) +/// +/// # Returns +/// True if the track passes all filters, false otherwise +pub fn should_include_track(track: &TrackInfo, ref_track: &TrackInfo, drift_opt: Option<f32>) -> bool { + // Apply drift filtering if specified + if let Some(drift) = drift_opt + && !is_within_drift_tolerance(track, ref_track, drift) + { + return false; + } + + // Filter by Key compatibility + is_key_compatible(track, ref_track) +} diff --git a/matchakey/src/key.rs b/matchakey/src/key.rs @@ -0,0 +1,151 @@ +// This module handles music key parsing and determines musical compatibility between keys. + +use regex::Regex; +use std::sync::OnceLock; + +#[derive(Debug, Clone, PartialEq)] +pub enum Mode { + Major, + Minor, +} + +/// Keys are represented internally using the Camelot Wheel system where: +/// - Root values range from 1-12 corresponding to positions on the wheel +/// - Mode indicates whether the key is major or minor +#[derive(Debug, Clone, PartialEq)] +pub struct Key { + /// Root note position (1-12 on the Camelot wheel) + pub root: u8, + /// Key mode (major/minor) + pub mode: Mode, +} + +/// Cache for compiled regex to avoid recompilation +static CAMELOT_REGEX: OnceLock<Regex> = OnceLock::new(); + +impl Key { + /// Parse a key from a string representation + /// + /// Supports both Camelot notation (e.g., "8A", "11B") and standard notation + /// (e.g., "C Major", "Am", "F#m", "Eb") + /// + /// # Arguments + /// * `s` - String representation of the key + /// + /// # Returns + /// Some(Key) if parsing succeeds, None otherwise + /// + /// # Panics + /// Panics if the `CAMELOT_REGEX` fails to build. + pub fn from_string(s: &str) -> Option<Self> { + let s = s.trim(); + + // Get or initialize the cached regex + let camelot_re = + CAMELOT_REGEX.get_or_init(|| Regex::new(r"(?i)^(\d{1,2})([AB])$").unwrap()); + + if let Some(caps) = camelot_re.captures(s) { + let num: u8 = caps[1].parse().ok()?; + let letter = &caps[2]; + + if !(1..=12).contains(&num) { + return None; + } + + // Convert Camelot to Circle of Fifths position (which maps to root notes) + // Map Standard to Camelot logic (1-12) + + let mode = if letter.to_uppercase() == "A" { + Mode::Minor + } else { + Mode::Major + }; + return Some(Key { root: num, mode }); + } + + // Try parsing Standard notation (e.g., C Major, Am, F#m, Eb) + // Map Standard to Camelot + // Major: + // B (1B), F# (2B), Db (3B), Ab (4B), Eb (5B), Bb (6B), F (7B), C (8B), G (9B), D (10B), A (11B), E (12B) + // Minor: + // Abm (1A), Ebm (2A), Bbm (3A), Fm (4A), Cm (5A), Gm (6A), Dm (7A), Am (8A), Em (9A), Bm (10A), F#m(11A), Dbm(12A) + + // Normalize string + let s_norm = s.replace(' ', "").to_lowercase(); + + let (note, is_minor) = if s_norm.ends_with("minor") + || s_norm.ends_with("min") + || (s_norm.ends_with('m') && !s_norm.ends_with("major")) + { + let base = s_norm + .trim_end_matches("minor") + .trim_end_matches("min") + .trim_end_matches('m'); + (base, true) + } else if s_norm.ends_with("major") || s_norm.ends_with("maj") { + let base = s_norm.trim_end_matches("major").trim_end_matches("maj"); + (base, false) + } else { + (s_norm.as_str(), false) // Default to Major if not specified + }; + + let camelot_num = match (note, is_minor) { + ("b", false) | ("ab" | "g#", true) => 1, + ("f#" | "gb", false) | ("eb" | "d#", true) => 2, + ("db" | "c#", false) | ("bb" | "a#", true) => 3, + ("ab" | "g#", false) | ("f", true) => 4, + ("eb" | "d#", false) | ("c", true) => 5, + ("bb" | "a#", false) | ("g", true) => 6, + ("f", false) | ("d", true) => 7, + ("c", false) | ("a", true) => 8, + ("g", false) | ("e", true) => 9, + ("d", false) | ("b", true) => 10, + ("a", false) | ("f#" | "gb", true) => 11, + ("e", false) | ("db" | "c#", true) => 12, + + _ => return None, + }; + + let mode = if is_minor { Mode::Minor } else { Mode::Major }; + Some(Key { + root: camelot_num, + mode, + }) + } + + #[must_use] + pub fn is_compatible(&self, other: &Key) -> bool { + // Exact match + if self == other { + return true; + } + + // Same mode, +/- 1 hour (wrap around 12) + if self.mode == other.mode { + let diff = (i16::from(self.root) - i16::from(other.root)).abs(); + if diff == 1 || diff == 11 { + return true; + } + } + + // Different mode, same hour (Relative Major/Minor) e.g. 8A <-> 8B + if self.root == other.root && self.mode != other.mode { + return true; + } + + false + } + + /// Convert the key to Camelot notation string (e.g., "8A", "11B") + /// + /// # Returns + /// A string in Camelot notation format + #[must_use] + pub fn to_camelot(&self) -> String { + let letter = match self.mode { + Mode::Minor => "A", + Mode::Major => "B", + }; + format!("{}{}", self.root, letter) + } +} diff --git a/matchakey/src/lib.rs b/matchakey/src/lib.rs @@ -0,0 +1,5 @@ +pub mod db; +pub mod drift; +pub mod key; +pub mod track; +pub mod utils; diff --git a/matchakey/src/main.rs b/matchakey/src/main.rs @@ -0,0 +1,218 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use colored::Colorize; +use rand::seq::SliceRandom; +use rayon::prelude::*; +use std::path::PathBuf; +use tracing::{debug, info, warn}; +use walkdir::WalkDir; + + + +use matchakey::track::{get_track_info, TrackInfo}; +use matchakey::utils::is_allowed_extension; +use matchakey::db::find_tracks_from_mixxx; +use matchakey::key; + +fn get_default_mixxx_db_path() -> Option<PathBuf> { + std::env::var_os("HOME").map(|home| { + let mut path = PathBuf::from(home); + path.push(".mixxx"); + path.push("mixxxdb.sqlite"); + path + }) +} + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Reference track file to compare against + #[arg(required = true)] + reference: PathBuf, + + /// Directories to search for compatible tracks + #[arg(required_unless_present_any = &["mixxx", "mixxx_path"])] + source_path: Vec<PathBuf>, + + /// Use Mixxx database (defaults to ~/.mixxx/mixxxdb.sqlite) + #[arg(long)] + mixxx: bool, + + /// Custom path to Mixxx database (implies --mixxx if provided) + #[arg(long, conflicts_with = "mixxx")] + mixxx_path: Option<PathBuf>, + + /// Number of tracks to fetch from Mixxx database for shuffling (default: 1000) + #[arg(long)] + mixxx_db_size: Option<u32>, + + /// Limit number of tracks to display from Mixxx database (default: 10) + #[arg(long)] + limit: Option<u32>, + + /// BPM drift tolerance (default: 3.0) + #[arg(long)] + drift: Option<f32>, + + /// Enable verbose output + #[arg(short, long)] + verbose: bool, +} + +fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let mut args = Args::parse(); + + // Determine the mixxx database path + // If mixxx_path is provided, use it + // If mixxx flag is provided without path, use default + // Otherwise, don't use mixxx functionality + let mixxx_db_path = if let Some(custom_path) = args.mixxx_path { + Some(custom_path) + } else if args.mixxx { + get_default_mixxx_db_path() + } else { + None + }; + + // Update args to have the resolved path + args.mixxx_path = mixxx_db_path; + + info!("Starting matchakey with args: {:?}", args); + + println!( + "{} {}", + " Analyzing reference track:".bold().blue(), + args.reference.display() + ); + let ref_track = get_track_info(&args.reference).context("Failed to read reference track")?; + debug!( + "Reference track info: bpm={}, key={:?}", + ref_track.bpm, ref_track.key + ); + + if ref_track.bpm == 0.0 { + warn!("Reference track has no BPM metadata."); + println!( + "{}", + "Warning: Reference track has no BPM metadata.".yellow() + ); + } + + let ref_key_str = ref_track + .key + .as_ref() + .map_or_else(|| "Unknown".to_string(), key::Key::to_camelot); + println!( + " Reference BPM: {}", + format!("{:.1}", ref_track.bpm).green() + ); + println!( + " Reference Key: {} ({})", + ref_key_str.green(), + ref_track.key_raw + ); + println!("---------------------------------------------------"); + + if let Some(db_path) = &args.mixxx_path { + let db_fetch_size = args.mixxx_db_size.unwrap_or(1000); // Default to 1000 if not specified + let mut results = + find_tracks_from_mixxx(db_path, Some(db_fetch_size), args.drift, &ref_track)?; // Fetch configurable amount + + // Shuffle all fetched results + let mut rng = rand::rng(); + results.shuffle(&mut rng); + + let display_count = args.limit.unwrap_or(10) as usize; + for track in results.into_iter().take(display_count) { + let artist = track.artist.as_deref().unwrap_or("Unknown"); + let title = track.title.as_deref().unwrap_or("Unknown"); + let datetime_added = track.datetime_added.as_deref().unwrap_or("Unknown"); + + let bpm_display = format!("{:.1}", track.bpm); + let key_display = track + .key + .as_ref() + .map_or_else(|| track.key_raw.clone(), key::Key::to_camelot); + + println!( + "{:<6} {:<4} | {:<20} - {:<20} ({})", + bpm_display.cyan(), + key_display.yellow(), + artist.bold(), + title, + datetime_added.dimmed() + ); + } + } else { + find_and_process_tracks(&args, &ref_track); + } + + info!("Completed processing successfully"); + Ok(()) +} + +fn find_and_process_tracks(args: &Args, ref_track: &TrackInfo) { + let files: Vec<PathBuf> = args + .source_path + .iter() + .flat_map(|dir| { + WalkDir::new(dir).into_iter().filter_map(|entry| { + entry + .map_err(|e| { + warn!("Failed to read directory entry: {}", e); + e + }) + .ok() + }) + }) + .filter(|e| e.path().is_file()) + .filter(|e| is_allowed_extension(e.path())) + .map(|e| e.path().to_path_buf()) + .collect(); + + info!("Found {} files to scan.", files.len()); + if args.verbose { + println!("Found {} files to scan.", files.len()); + } + + let mut results: Vec<TrackInfo> = files + .par_iter() + .filter_map(|path| get_track_info(path).ok()) + .filter(|info| { + // Use the drift module to check if track should be included + matchakey::drift::should_include_track(info, ref_track, args.drift) + }) + .collect(); + + // Sort results by BPM difference + results.sort_by(|a, b| { + let diff_a = (a.bpm - ref_track.bpm).abs(); + let diff_b = (b.bpm - ref_track.bpm).abs(); + diff_a + .partial_cmp(&diff_b) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + for track in results { + let artist = track.artist.as_deref().unwrap_or("Unknown"); + let title = track.title.as_deref().unwrap_or("Unknown"); + let datetime_added = track.datetime_added.as_deref().unwrap_or("Unknown"); + + let bpm_display = format!("{:.1}", track.bpm); + let key_display = track + .key + .as_ref() + .map_or_else(|| track.key_raw.clone(), key::Key::to_camelot); + + println!( + "{:<6} {:<4} | {:<20} - {:<20} ({})", + bpm_display.cyan(), + key_display.yellow(), + artist.bold(), + title, + datetime_added.dimmed() + ); + } +} diff --git a/matchakey/src/track.rs b/matchakey/src/track.rs @@ -0,0 +1,80 @@ +//! Track metadata handling +//! +//! This module handles reading and parsing metadata from audio files. + +use anyhow::{Context, Result}; +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::ItemKey; +use lofty::config::ParseOptions; +use std::path::Path; + +/// Information extracted from a track's metadata +#[derive(Debug)] +pub struct TrackInfo { + /// Beats per minute (tempo) + pub bpm: f32, + /// Musical key (if available) + pub key: Option<Key>, + /// Raw key string from the file + pub key_raw: String, + /// Track title (if available) + pub title: Option<String>, + /// Track artist (if available) + pub artist: Option<String>, + /// Date and time when track was added to the database + pub datetime_added: Option<String>, +} + +use crate::key::Key; + +/// Extract metadata information from an audio file +/// +/// # Arguments +/// * `path` - Path to the audio file +/// +/// # Returns +/// `TrackInfo` containing the extracted metadata, or an error if the file could not be read +/// +/// # Errors +/// +/// This function will return an error if the file metadata cannot be read. +pub fn get_track_info(path: &Path) -> Result<TrackInfo> { + let tagged_file = Probe::open(path) + .context("Failed to open file")? + .options(ParseOptions::new().read_properties(false)) + .read() + .context("Failed to read metadata")?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let bpm_val = tag + .and_then(|t| t.get_string(ItemKey::Bpm)) + .and_then(|s| s.parse::<f32>().ok()) + .unwrap_or(0.0); + + let key_raw = tag + .and_then(|t| t.get_string(ItemKey::InitialKey)) + .map(ToString::to_string) + .unwrap_or_default(); + + let key = Key::from_string(&key_raw); + + let title = tag + .and_then(|t| t.get_string(ItemKey::TrackTitle)) + .map(String::from); + let artist = tag + .and_then(|t| t.get_string(ItemKey::TrackArtist)) + .map(String::from); + + Ok(TrackInfo { + bpm: bpm_val, + key, + key_raw, + title, + artist, + datetime_added: None, // Not available from file metadata, only from database + }) +} diff --git a/matchakey/src/utils.rs b/matchakey/src/utils.rs @@ -0,0 +1,12 @@ +use std::collections::HashSet; +use std::path::Path; + +#[must_use] +pub fn is_allowed_extension(path: &Path) -> bool { + let allowed_extensions: HashSet<&str> = ["mp3", "flac", "aiff"].iter().copied().collect(); + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + allowed_extensions.contains(ext.to_lowercase().as_str()) + } else { + false + } +} diff --git a/matchakey/tests/integration_tests.rs b/matchakey/tests/integration_tests.rs @@ -0,0 +1,75 @@ +//! Integration tests for the matchakey application + +use matchakey::key::{Key, Mode}; + +#[test] +fn test_key_parsing_integration() { + // Test Camelot notation parsing + let key = Key::from_string("8A").unwrap(); + assert_eq!(key.root, 8); + assert_eq!(key.mode, Mode::Minor); + + let key = Key::from_string("8B").unwrap(); + assert_eq!(key.root, 8); + assert_eq!(key.mode, Mode::Major); + + // Test standard notation parsing + let key = Key::from_string("C").unwrap(); + assert_eq!(key.root, 8); // C maps to 8B in Camelot + assert_eq!(key.mode, Mode::Major); + + let key = Key::from_string("Am").unwrap(); + assert_eq!(key.root, 8); // Am maps to 8A in Camelot + assert_eq!(key.mode, Mode::Minor); +} + +#[test] +fn test_key_compatibility_integration() { + let key1 = Key { + root: 8, + mode: Mode::Major, + }; // 8B + let key2 = Key { + root: 8, + mode: Mode::Minor, + }; // 8A - relative minor + + assert!(key1.is_compatible(&key2)); + assert!(key2.is_compatible(&key1)); + + let key3 = Key { + root: 9, + mode: Mode::Major, + }; // 9B - adjacent key + assert!(key1.is_compatible(&key3)); + assert!(key3.is_compatible(&key1)); + + let key4 = Key { + root: 7, + mode: Mode::Major, + }; // 7B - adjacent key (wrapping) + assert!(key1.is_compatible(&key4)); + assert!(key4.is_compatible(&key1)); + + let key5 = Key { + root: 6, + mode: Mode::Major, + }; // 6B - not adjacent + assert!(!key1.is_compatible(&key5)); + assert!(!key5.is_compatible(&key1)); +} + +#[test] +fn test_camelot_conversion_integration() { + let key = Key { + root: 8, + mode: Mode::Major, + }; + assert_eq!(key.to_camelot(), "8B"); + + let key = Key { + root: 8, + mode: Mode::Minor, + }; + assert_eq!(key.to_camelot(), "8A"); +} diff --git a/matchakey/tests/utils_tests.rs b/matchakey/tests/utils_tests.rs @@ -0,0 +1,19 @@ +//! Integration tests for utility functions + +use matchakey::utils::is_allowed_extension; +use std::path::Path; + +#[test] +fn test_is_allowed_extension_integration() { + assert!(is_allowed_extension(Path::new("test.mp3"))); + assert!(is_allowed_extension(Path::new("test.flac"))); + assert!(is_allowed_extension(Path::new("test.aiff"))); + assert!(is_allowed_extension(Path::new("TEST.MP3"))); + assert!(is_allowed_extension(Path::new("path/to/file.FLAC"))); + + assert!(!is_allowed_extension(Path::new("test.wav"))); + assert!(!is_allowed_extension(Path::new("test.ogg"))); + assert!(!is_allowed_extension(Path::new("test.m4a"))); + assert!(!is_allowed_extension(Path::new("test.txt"))); + assert!(!is_allowed_extension(Path::new("test"))); +} diff --git a/misc/hehe.png b/misc/hehe.png Binary files differ. diff --git a/nts/BUILD b/nts/BUILD @@ -0,0 +1,19 @@ +load("@rules_python//python:defs.bzl", "py_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +py_binary( + name = "nts", + srcs = ["nts.py"], + main = "nts.py", + python_version = "PY3", + deps = [ + "@pypi//requests", + ], +) + +local_deploy( + name = "deploy", + srcs = [":nts"], + copy_runfiles = True, + +) diff --git a/nts/nts.py b/nts/nts.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +import argparse +import requests +import sys +import json +from typing import Any, cast +from urllib.parse import urlparse, quote + + +class NTSTracklistFetcher: + def __init__(self) -> None: + self.session: requests.Session = requests.Session() + self.session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", + "Accept": "application/json", + } + ) + + def extract_episode_path(self, url: str) -> str | None: + """Extract show and episode path from URL.""" + try: + parsed = urlparse(url) + path_parts = [p for p in parsed.path.split("/") if p] + + if ( + len(path_parts) >= 4 + and path_parts[0] == "shows" + and path_parts[2] == "episodes" + ): + show_name = path_parts[1] + episode_name = path_parts[3] + return f"{show_name}/episodes/{episode_name}" + return None + except Exception as e: + print(f"Error extracting path: {e}", file=sys.stderr) + return None + + def extract_episode_name(self, url: str) -> str | None: + """Extract episode name for filename from URL.""" + try: + parsed = urlparse(url) + path_parts = [p for p in parsed.path.split("/") if p] + + if ( + len(path_parts) >= 4 + and path_parts[0] == "shows" + and path_parts[2] == "episodes" + ): + return path_parts[3] + return None + except Exception as e: + print(f"Error extracting episode name: {e}", file=sys.stderr) + return None + + def get_episode_data(self, episode_path: str) -> dict[str, Any]: + """Fetch episode data from the API.""" + encoded_path = "/".join(quote(part) for part in episode_path.split("/")) + url = f"https://www.nts.live/api/v2/shows/{encoded_path}" + + if "--debug" in sys.argv: + print(f"DEBUG: Requesting URL: {url}", file=sys.stderr) + + response = self.session.get(url) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + + def save_tracklist(self, episode_data: dict[str, Any], filename: str) -> None: + """Save tracks to a text file.""" + if "--debug" in sys.argv: + print( + f"DEBUG: Response data: {json.dumps(episode_data, indent=2)}", + file=sys.stderr, + ) + + # Extract tracks from the embeds.tracklist.results path + embeds = cast(dict[str, Any], episode_data.get("embeds", {})) + tracklist_container = cast(dict[str, Any], embeds.get("tracklist", {})) + tracklist = cast(list[dict[str, Any]], tracklist_container.get("results", [])) + + if not tracklist: + print("No tracks found in this episode", file=sys.stderr) + return + + with open(filename, "w", encoding="utf-8") as f: + for track in tracklist: + artist = cast(str, track.get("artist", "Unknown Artist")) + title = cast(str, track.get("title", "Unknown Title")) + line = f"{artist} - {title}" + print(line) + f.write(line + "\n") + + def fetch_and_save_tracklist(self, url: str) -> None: + """Main method to fetch and save tracklist.""" + episode_path = self.extract_episode_path(url) + episode_name = self.extract_episode_name(url) + + if not episode_path or not episode_name: + raise ValueError("Could not extract episode information from URL") + + filename = f"{episode_name}.txt" + + if "--debug" in sys.argv: + print(f"DEBUG: Extracted path: {episode_path}", file=sys.stderr) + print(f"DEBUG: Output filename: {filename}", file=sys.stderr) + + try: + episode_data = self.get_episode_data(episode_path) + self.save_tracklist(episode_data, filename) + except requests.exceptions.HTTPError: + raise + except Exception as e: + print(f"Error processing episode: {str(e)}", file=sys.stderr) + raise + + +def main(): + parser = argparse.ArgumentParser(description="Fetch tracklist from NTS Radio shows") + _ = parser.add_argument("url", help="URL of the NTS Radio show") + _ = parser.add_argument("--debug", action="store_true", help="Enable debug output") + + args = parser.parse_args() + + try: + fetcher = NTSTracklistFetcher() + fetcher.fetch_and_save_tracklist(args.url) + except requests.exceptions.RequestException: + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except Exception: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plants-client/BUILD b/plants-client/BUILD @@ -0,0 +1,10 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "plants-client", + aliases = { + "//plants-common:plants-common": "common", + }, + deps = all_crate_deps() + ["//plants-common"], +) diff --git a/plants-client/Cargo.toml b/plants-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "plants-client" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "plants" +path = "src/main.rs" + +[dependencies] +plants-common = { path = "../plants-common", package = "plants-common" } +anyhow = "1.0.98" +futures = "0.3.31" +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread", "time", "process", "io-util", "sync", "fs", "net"] } +zbus = { version = "5.9.0", features = ["option-as-array"] } +serde_json = "1.0.141" +clap = { version = "4.5", features = ["derive"] } diff --git a/plants-client/src/main.rs b/plants-client/src/main.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use clap::Parser; +use common::output::Output; +use common::status::Status; +use futures::StreamExt; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::RwLock; +use zbus::{Connection, proxy}; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(long)] + host: Option<String>, +} + +#[proxy( + default_service = "org.mtmn.Plants", + default_path = "/org/mtmn/Plants", + interface = "org.mtmn.Plants" +)] +trait PlantsDaemon { + #[zbus(signal)] + async fn update(&self, status: Status); +} + +type SharedOutput = Arc<RwLock<String>>; + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let output_state = Arc::new(RwLock::new(String::new())); + + let initial = Output::not_connected(); + initial.print(); + + // Spawn DBus listener task + let output_clone = output_state.clone(); + let mut dbus_handle = tokio::spawn(async move { + if let Err(e) = listen_dbus(output_clone).await { + eprintln!("DBus listener error: {e}"); + } + }); + + // Create web server if host is provided + if let Some(host) = args.host { + let listener = TcpListener::bind(&host).await?; + + loop { + // Wait for either the dbus listener (which shouldn't exit) or a new connection + tokio::select! { + res = listener.accept() => { + match res { + Ok((socket, _)) => { + let state = output_state.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, state).await { + eprintln!("Error handling connection: {e}"); + } + }); + } + Err(e) => eprintln!("Accept error: {e}"), + } + } + _ = &mut dbus_handle => { + // DBus handle finished + break; + } + } + } + } else { + // If no host is defined, just await the dbus task + if let Err(e) = dbus_handle.await { + eprintln!("DBus task error: {e}"); + } + } + + Ok(()) +} + +async fn handle_connection(socket: tokio::net::TcpStream, state: SharedOutput) -> Result<()> { + let (reader, mut writer) = socket.into_split(); + let mut reader = BufReader::new(reader); + + let mut line = String::new(); + reader.read_line(&mut line).await?; + + let json = state.read().await.clone(); + let json = if json.is_empty() { + "{}".to_string() + } else { + json + }; + + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + \r\n\ + {}", + json.len(), + json + ); + + writer.write_all(response.as_bytes()).await?; + writer.flush().await?; + + Ok(()) +} + +async fn listen_dbus(output_state: SharedOutput) -> Result<()> { + let connection = Connection::session().await?; + let proxy = PlantsDaemonProxy::new(&connection).await?; + let mut stream = proxy.receive_update().await?; + + while let Some(msg) = stream.next().await { + let status = msg.args()?.status; + + let merged_output = Output::from_status(&status); + merged_output.print(); + + let json = serde_json::to_string(&merged_output).unwrap_or_else(|_| "{}".to_string()); + let mut state = output_state.write().await; + *state = json; + } + + Ok(()) +} diff --git a/plants-common/BUILD b/plants-common/BUILD @@ -0,0 +1,13 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("@rules_rust//rust:defs.bzl", "rust_library") + +rust_library( + name = "plants-common", + srcs = glob(["src/**/*.rs"]), + edition = "2024", + rustc_flags = [ + "-Ctarget-cpu=native", + ], + visibility = ["//visibility:public"], + deps = all_crate_deps(), +) diff --git a/plants-common/Cargo.toml b/plants-common/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "plants-common" +version = "0.1.0" +edition = "2024" + +[lib] +path = "src/lib.rs" + +[dependencies] +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.141" +zbus = { version = "5.9.0", features = ["option-as-array"] } +zvariant = "5.9.2" diff --git a/plants-common/src/lib.rs b/plants-common/src/lib.rs @@ -0,0 +1,2 @@ +pub mod output; +pub mod status; diff --git a/plants-common/src/output.rs b/plants-common/src/output.rs @@ -0,0 +1,144 @@ +use std::fmt::Write as _; +use std::io::{Write, stdout}; + +use serde::Serialize; + +use crate::status::{BatteryStatus, Components, Status}; + +#[derive(Serialize, Debug)] +pub struct Output { + text: String, + tooltip: Option<String>, + class: Option<String>, + percentage: Option<f32>, +} + +impl Output { + #[must_use] + pub fn not_connected() -> Self { + Output { + text: "󰟦".into(), + tooltip: Some("Daemon not active".into()), + class: Some("disconnected".into()), + percentage: None, + } + } + + #[must_use] + pub fn from_status(status: &Status) -> Self { + if !status.is_valid() { + return Output::default(); + } + + let mut tooltip = String::new(); + if let Some(metadata) = &status.metadata { + let _ = writeln!(tooltip, "{} ({})", metadata.name, metadata.model); + } + + let Components { left, right, case } = &status.components; + for (idx, (name, component)) in [("Left", left), ("Right", right), ("Case", case)] + .iter() + .enumerate() + { + let Some(component) = component else { + continue; + }; + + let icon = match component.status { + BatteryStatus::Charging => "󰢝", + BatteryStatus::Discharging => match idx { + 0 => status.ear.left, + 1 => status.ear.right, + _ => crate::status::EarStatus::Disconnected, + } + .icon(), + BatteryStatus::Disconnected => continue, + }; + + let _ = writeln!(tooltip, "{icon} {name}: {}%", component.level); + } + + for device in &status.devices { + let icon = if let Some(text) = &device.text { + text.as_str() + } else { + match device.status { + BatteryStatus::Charging => "󰢝", + BatteryStatus::Discharging => "󰂯", + BatteryStatus::Disconnected => continue, + } + }; + let _ = writeln!(tooltip, "{icon} {}: {}%", device.name, device.battery); + } + + let mut min_level = status.min_pods(); + for device in &status.devices { + if device.status == BatteryStatus::Discharging { + min_level = min_level.min(device.battery); + } + } + + let is_low = min_level <= 15; + let class = ["connected", "connected-low"][usize::from(is_low)]; + + // Base text for pods + let mut text_parts = Vec::new(); + + let battery = ["", "󱃍"][usize::from(is_low)]; + if status.min_pods() != u8::MAX { + let min_pods = status.min_pods(); + text_parts.push(format!("󱡏{battery} {min_pods}%")); + } + + for device in &status.devices { + let icon = if let Some(text) = &device.text { + text.as_str() + } else { + match device.status { + BatteryStatus::Charging => "󰢝", + BatteryStatus::Discharging => "󰂯", + BatteryStatus::Disconnected => continue, + } + }; + text_parts.push(format!("{icon} {}%", device.battery)); + } + + let text = if text_parts.is_empty() { + // Default empty/disconnected state + format!("󱡏{battery}") + } else { + text_parts.join(" ") + }; + + Output { + text, + tooltip: Some(tooltip[..tooltip.len() - 1].to_owned()), + class: Some(class.into()), + percentage: if min_level == u8::MAX { + None + } else { + Some(f32::from(min_level) / 100.0) + }, + } + } + + #[allow(clippy::missing_panics_doc)] + pub fn print(&self) { + let str = serde_json::to_string(&self).unwrap(); + + let mut stdout = stdout(); + let _ = stdout.write_fmt(format_args!("{str}\n")); + let _ = stdout.flush(); + } +} + +impl Default for Output { + fn default() -> Self { + Output { + text: "󱡐".into(), + tooltip: None, + class: Some("disconnected".into()), + percentage: None, + } + } +} diff --git a/plants-common/src/status.rs b/plants-common/src/status.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use zbus::zvariant::Type; + +#[derive(Default, Hash, Clone, Debug, Serialize, Deserialize, Type)] +pub struct Status { + pub metadata: Option<Metadata>, + pub components: Components, + pub ear: InEar, + pub devices: Vec<GenericDeviceStatus>, +} + +#[derive(Hash, Debug, Clone, Serialize, Deserialize, Type)] +pub struct GenericDeviceStatus { + pub name: String, + pub battery: u8, + pub text: Option<String>, + pub status: BatteryStatus, +} + +#[derive(Hash, Clone, Debug, Serialize, Deserialize, Type)] +pub struct Metadata { + pub name: String, + pub model: String, +} + +#[derive(Default, Hash, Clone, Debug, Serialize, Deserialize, Type)] +pub struct Components { + pub left: Option<ComponentStatus>, + pub right: Option<ComponentStatus>, + pub case: Option<ComponentStatus>, +} + +#[derive(Default, Hash, Clone, Debug, Serialize, Deserialize, Type)] +pub struct InEar { + pub left: EarStatus, + pub right: EarStatus, +} + +#[derive(Hash, Clone, Debug, Serialize, Deserialize, Type)] +pub struct ComponentStatus { + pub level: u8, + pub status: BatteryStatus, +} + +#[derive(Hash, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)] +pub enum BatteryStatus { + Charging, + Discharging, + Disconnected, +} + +#[derive(Default, Hash, Clone, Copy, Debug, Serialize, Deserialize, Type)] +pub enum EarStatus { + InEar, + NotInEar, + InCase, + #[default] + Disconnected, +} + +impl Status { + #[must_use] + pub fn hash(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + Hash::hash(self, &mut hasher); + hasher.finish() + } + + #[must_use] + pub fn is_valid(&self) -> bool { + let Components { left, right, case } = &self.components; + left.is_some() || right.is_some() || case.is_some() || !self.devices.is_empty() + } + + #[must_use] + pub fn min_pods(&self) -> u8 { + let mut out = u8::MAX; + + let Components { left, right, .. } = &self.components; + for component in [&left, &right] { + if let Some(component) = &component + && matches!(component.status, BatteryStatus::Discharging) + { + out = out.min(component.level); + } + } + + out + } +} + +impl Components { + pub fn as_arr_mut(&mut self) -> [&mut Option<ComponentStatus>; 3] { + [&mut self.left, &mut self.right, &mut self.case] + } +} + +impl EarStatus { + #[must_use] + pub fn icon(&self) -> &'static str { + match self { + EarStatus::InEar => "󱡏", + EarStatus::NotInEar => "󱡒", + EarStatus::InCase => "󱡑", + EarStatus::Disconnected => "", + } + } +} diff --git a/plants-daemon/BUILD b/plants-daemon/BUILD @@ -0,0 +1,10 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "plants-daemon", + aliases = { + "//plants-common:plants-common": "common", + }, + deps = all_crate_deps() + ["//plants-common"], +) diff --git a/plants-daemon/Cargo.toml b/plants-daemon/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "plants-daemon" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "plants-daemon" +path = "src/main.rs" + +[dependencies] +common = { path = "../plants-common", package = "plants-common" } +anyhow = "1.0.98" +bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] } +futures = "0.3.31" +regex = "1.10.4" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.141" +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread", "time", "process", "io-util", "sync", "fs", "net"] } +toml = "1.0" +zbus = { version = "5.9.0", features = ["option-as-array"] } +maestro = { git = "https://github.com/qzed/pbpctrl" } +uuid = { version = "=1.21", features = ["v4", "macro-diagnostics"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +clap = { version = "4.5", features = ["derive"] } diff --git a/plants-daemon/src/airpods.rs b/plants-daemon/src/airpods.rs @@ -0,0 +1,215 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Result; +use bluer::{ + DeviceEvent, DeviceProperty, ErrorKind, + rfcomm::{Profile, Role, Stream}, +}; +use futures::StreamExt; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time, +}; +use zbus::object_server::InterfaceRef; + +use crate::{ + airpods_consts::{ + AIRPODS_SERVICE, FEATURES_ACK, HANDSHAKE, HANDSHAKE_ACK, REQUEST_NOTIFICATIONS, + SET_SPECIFIC_FEATURES, + }, + daemon_impl::{PlantsDaemon, PlantsDaemonSignals}, + packets::{ + battery::{BatteryPacket, Pod}, + in_ear::InEarPacket, + metadata::MetadataPacket, + }, +}; +use common::status::Status; + +#[derive(Default)] +struct LocalState { + primary: Pod, + // Keep a local copy to track changes and hash, + // but we also sync to the global shared state. + status: Status, +} + +pub async fn run(interface: InterfaceRef<PlantsDaemon>, state: Arc<Mutex<Status>>) -> Result<()> { + // Use a separate session for AirPods scanning + let session = bluer::Session::new().await?; + let adapter = session.default_adapter().await?; + adapter.set_powered(true).await?; + + let profile = Profile { + uuid: AIRPODS_SERVICE, + role: Some(Role::Client), + service: Some(AIRPODS_SERVICE), + ..Default::default() + }; + let mut profile = session.register_profile(profile).await?; + + tracing::info!("Scanning for AirPods"); + let device = get_airpods(&adapter).await?; + tracing::info!("Found AirPods [{}]", device.address()); + + // Connection loop + let device_addr = device.address(); + let device = adapter.device(device_addr)?; // Re-get device to ensure we have valid handle + + // Spawn a task to maintain connection + let device_cl = device.clone(); + tokio::spawn(async move { + // Initial connect attempt + if let Ok(true) = device_cl.is_connected().await { + let _ = device_cl.connect_profile(&AIRPODS_SERVICE).await; + } + + if let Ok(mut events) = device_cl.events().await { + while let Some(event) = events.next().await { + if let DeviceEvent::PropertyChanged(DeviceProperty::Connected(true)) = event { + // Retry connecting profile until success or error is not InProgress + while let Err(err) = device_cl.connect_profile(&AIRPODS_SERVICE).await { + if err.kind == ErrorKind::InProgress { + time::sleep(Duration::from_millis(500)).await; + } else { + // Backoff slightly + time::sleep(Duration::from_millis(100)).await; + } + } + } + } + } + }); + + while let Some(handle) = profile.next().await { + tracing::debug!("AirPods profile connected"); + let mut stream = handle.accept()?; + match handle_connection(&interface, &state, &mut stream).await { + Ok(()) => { + tracing::info!("AirPods connection closed normally"); + } + Err(e) => { + tracing::warn!("AirPods connection error: {}", e); + } + } + + // Clear status on disconnect + { + let mut gs = state.lock().unwrap(); + gs.components = common::status::Components::default(); + gs.ear = common::status::InEar::default(); + gs.metadata = None; + } + + let status = { + let gs = state.lock().unwrap(); + gs.clone() + }; + interface.update(status).await?; + } + + Ok(()) +} + +async fn handle_connection( + interface: &InterfaceRef<PlantsDaemon>, + global_state: &Arc<Mutex<Status>>, + stream: &mut Stream, +) -> Result<()> { + stream.write_all(HANDSHAKE).await?; + + let mut local_state = LocalState::default(); + + loop { + let mut data = Vec::new(); + + loop { + let mut buffer = vec![0; 1024]; + let bytes = stream.read(&mut buffer).await?; + if bytes == 0 { + anyhow::bail!("Stream ended"); + } + data.extend_from_slice(&buffer[..bytes]); + + if bytes < buffer.len() { + break; + } + } + + if data.starts_with(HANDSHAKE_ACK) { + stream.write_all(SET_SPECIFIC_FEATURES).await?; + } else if data.starts_with(FEATURES_ACK) { + stream.write_all(REQUEST_NOTIFICATIONS).await?; + } else { + let hash = local_state.status.hash(); + got_packet(&mut local_state, &data); + + if hash != local_state.status.hash() { + // Update global state + { + let mut gs = global_state.lock().unwrap(); + gs.components = local_state.status.components.clone(); // Assuming Clone is derived + gs.ear = local_state.status.ear.clone(); // Assuming Clone + // Metadata? + if let Some(m) = &local_state.status.metadata { + gs.metadata = Some(common::status::Metadata { + name: m.name.clone(), + model: m.model.clone(), + }); + } + } + + let status = { + let gs = global_state.lock().unwrap(); + gs.clone() + }; + interface.update(status).await?; + } + } + } +} + +fn got_packet(state: &mut LocalState, data: &[u8]) { + if let Some(metadata) = MetadataPacket::parse(data) { + tracing::debug!("Got Metadata: {:?}", metadata); + state.status.metadata = Some(metadata.into()); + } else if let Some(battery) = BatteryPacket::parse(data) { + tracing::debug!("Got Battery: {:?}", battery); + state.primary = battery.primary; + + // Sync local components + if let Some(l) = battery.left { + state.status.components.left = Some(l.into()); + } + if let Some(r) = battery.right { + state.status.components.right = Some(r.into()); + } + if let Some(c) = battery.case { + state.status.components.case = Some(c.into()); + } + } else if let Some(in_ear) = InEarPacket::parse(data) { + tracing::debug!("Got InEar: {:?}", in_ear); + + if let Some([left, right]) = in_ear.get(state.primary) { + state.status.ear.left = left.into(); + state.status.ear.right = right.into(); + } + } +} + +async fn get_airpods(adapter: &bluer::Adapter) -> Result<bluer::Device> { + loop { + let connected = adapter.device_addresses().await?; + for addr in connected { + let device = adapter.device(addr)?; + // Can't always get UUIDs immediately, handling error gracefully + let uuids = device.uuids().await.ok().flatten().unwrap_or_default(); + if uuids.contains(&AIRPODS_SERVICE) { + return Ok(device); + } + } + + time::sleep(Duration::from_secs(5)).await; + } +} diff --git a/plants-daemon/src/airpods_consts.rs b/plants-daemon/src/airpods_consts.rs @@ -0,0 +1,20 @@ +use uuid::{Uuid, uuid}; + +pub const AIRPODS_SERVICE: Uuid = uuid!("74ec2172-0bad-4d01-8f77-997b2be0722a"); + +pub const HANDSHAKE: &[u8] = &[ + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub const BATTERY_STATUS: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x04, 0x00]; +pub const METADATA: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x1d]; +pub const EAR_DETECTION: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x06, 0x00]; +pub const HANDSHAKE_ACK: &[u8] = &[0x01, 0x00, 0x04, 0x00]; +pub const FEATURES_ACK: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x2b, 0x00]; + +pub const REQUEST_NOTIFICATIONS: &[u8] = &[ + 0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, +]; +pub const SET_SPECIFIC_FEATURES: &[u8] = &[ + 0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; diff --git a/plants-daemon/src/bluetooth.rs b/plants-daemon/src/bluetooth.rs @@ -0,0 +1,155 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Result; +use bluer::{AdapterEvent, Address}; +use common::status::{BatteryStatus, GenericDeviceStatus, Status}; +use futures::{StreamExt, pin_mut}; +use tokio::time; +use zbus::object_server::InterfaceRef; + +use crate::{ + config::Config, + daemon_impl::{PlantsDaemon, PlantsDaemonSignals}, +}; + +pub async fn run(interface: InterfaceRef<PlantsDaemon>, state: Arc<Mutex<Status>>) -> Result<()> { + let config = match crate::config::load_config().await { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load config: {e}"); + tracing::error!("Failed to load config: {}", e); + Config { + devices: std::collections::HashMap::default(), + buds: None, + } + } + }; + + if config.devices.is_empty() { + tracing::info!("No generic devices configured."); + return Ok(()); + } + + let session = bluer::Session::new().await?; + let adapter = session.default_adapter().await?; + adapter.set_powered(true).await?; + + let zbus_conn = zbus::Connection::system().await.ok(); + + // Initial update + update_devices(&adapter, &config, &state, &interface, zbus_conn.as_ref()).await; + + // Listen for events + let events = adapter.events().await?; + pin_mut!(events); + + // Simple debounce: don't update more often than once per second + let mut last_update = time::Instant::now(); + + loop { + tokio::select! { + // Polling interval + () = time::sleep(Duration::from_secs(30)) => { + update_devices(&adapter, &config, &state, &interface, zbus_conn.as_ref()).await; + } + Some(event) = events.next() => { + match event { + AdapterEvent::DeviceAdded(_) | AdapterEvent::DeviceRemoved(_) | AdapterEvent::PropertyChanged(_) => { + if last_update.elapsed() > Duration::from_millis(500) { + update_devices(&adapter, &config, &state, &interface, zbus_conn.as_ref()).await; + last_update = time::Instant::now(); + } + } + } + } + } + } +} + +async fn update_devices( + adapter: &bluer::Adapter, + config: &Config, + state: &Arc<Mutex<Status>>, + interface: &InterfaceRef<PlantsDaemon>, + zbus_conn: Option<&zbus::Connection>, +) { + let mut new_devices = Vec::new(); + + for (name, device_cfg) in &config.devices { + if device_cfg.device_type != "bluetooth" { + continue; + } + + let address: Address = match device_cfg.mac.parse() { + Ok(addr) => addr, + Err(e) => { + tracing::error!("Invalid MAC address for {}: {}", name, e); + continue; + } + }; + + // Check if device is available in adapter + if let Ok(device) = adapter.device(address) { + let is_connected = time::timeout(Duration::from_secs(2), device.is_connected()) + .await + .map(|r| r.unwrap_or(false)) + .unwrap_or(false); + + if is_connected { + let battery_pct = if let Some(conn) = zbus_conn { + get_battery_percentage(adapter.name(), address, conn).await + } else { + None + }; + + if let Some(pct) = battery_pct { + new_devices.push(GenericDeviceStatus { + name: name.clone(), + battery: pct, + text: device_cfg.text.clone(), + status: BatteryStatus::Discharging, + }); + } + } + } + } + + { + let mut status = state.lock().unwrap(); + status.devices = new_devices; + } + + let status = { + let status = state.lock().unwrap(); + status.clone() + }; + + if let Err(e) = interface.update(status).await { + tracing::error!("Failed to update waybar: {}", e); + } +} + +async fn get_battery_percentage( + adapter_name: &str, + address: bluer::Address, + conn: &zbus::Connection, +) -> Option<u8> { + // Construct object path: /org/bluez/{adapter}/dev_{mac_with_underscores} + let addr_str = address.to_string().replace(':', "_"); + let path_str = format!("/org/bluez/{adapter_name}/dev_{addr_str}"); + let path = zbus::zvariant::ObjectPath::try_from(path_str).ok()?; + + // Create a proxy for the Battery1 interface on the device path + let proxy = zbus::Proxy::new(conn, "org.bluez", &path, "org.bluez.Battery1") + .await + .ok()?; + + match proxy.get_property::<u8>("Percentage").await { + Ok(pct) => Some(pct), + Err(e) => { + tracing::debug!("Failed to get battery percentage for {}: {}", address, e); + None + } + } +} diff --git a/plants-daemon/src/config.rs b/plants-daemon/src/config.rs @@ -0,0 +1,35 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use tokio::fs; + +#[derive(Deserialize, Debug, Clone)] +pub struct DeviceConfig { + pub mac: String, + pub text: Option<String>, + pub device_type: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct BudsConfig { + pub mac: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Config { + pub devices: HashMap<String, DeviceConfig>, + pub buds: Option<BudsConfig>, +} + +pub async fn load_config() -> Result<Config> { + let home = std::env::var("HOME").context("Failed to get HOME env var")?; + let path = format!("{home}/.config/plants/devices.toml"); + + let content = fs::read_to_string(&path) + .await + .context(format!("Failed to read config file: {path}"))?; + + let config: Config = toml::from_str(&content).context("Failed to parse config file")?; + + Ok(config) +} diff --git a/plants-daemon/src/daemon_impl.rs b/plants-daemon/src/daemon_impl.rs @@ -0,0 +1,10 @@ +use common::status::Status; +use zbus::{interface, object_server::SignalEmitter}; + +pub struct PlantsDaemon; + +#[interface(name = "org.mtmn.Plants")] +impl PlantsDaemon { + #[zbus(signal)] + async fn update(emitter: &SignalEmitter<'_>, status: Status) -> zbus::Result<()>; +} diff --git a/plants-daemon/src/main.rs b/plants-daemon/src/main.rs @@ -0,0 +1,60 @@ +use anyhow::Result; + +use zbus::conn; + +use common::status::Status; +use std::sync::{Arc, Mutex}; + +mod airpods; +mod airpods_consts; +mod bluetooth; +mod config; +mod daemon_impl; +mod packets; +mod pbp; +mod pbp_client; + +use crate::daemon_impl::{PlantsDaemon, PlantsDaemonSignals}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")), + ) + .init(); + + let conn = conn::Builder::session()? + .name("org.mtmn.Plants")? + .serve_at("/org/mtmn/Plants", PlantsDaemon)? + .build() + .await?; + let interface = conn.object_server().interface("/org/mtmn/Plants").await?; + + let state = Arc::new(Mutex::new(Status::default())); + + PlantsDaemonSignals::update(&interface, Status::default()).await?; + + let bt_state = state.clone(); + let bt_interface = interface.clone(); + tokio::spawn(async move { + if let Err(e) = bluetooth::run(bt_interface, bt_state).await { + tracing::error!("Bluetooth generic error: {}", e); + } + }); + + let ap_state = state.clone(); + let ap_interface = interface.clone(); + tokio::spawn(async move { + if let Err(e) = airpods::run(ap_interface, ap_state).await { + tracing::error!("AirPods error: {}", e); + } + }); + + let pbp_state = state.clone(); + let pbp_interface = interface.clone(); + pbp::run(pbp_interface, pbp_state).await?; + + Ok(()) +} diff --git a/plants-daemon/src/packets/battery.rs b/plants-daemon/src/packets/battery.rs @@ -0,0 +1,132 @@ +use common::status; + +use crate::airpods_consts::BATTERY_STATUS; + +#[derive(Default, Debug)] +pub struct BatteryPacket { + pub left: Option<ComponentStatus>, + pub right: Option<ComponentStatus>, + pub case: Option<ComponentStatus>, + pub primary: Pod, +} + +#[derive(Debug, Clone, Copy)] +pub struct ComponentStatus { + pub level: u8, + pub status: BatteryStatus, +} + +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] +pub enum Pod { + Left, + Right, + #[default] + None, +} + +#[derive(Debug, Clone, Copy)] +#[repr(u8)] +pub enum BatteryStatus { + Charging = 0x01, + Discharging = 0x02, + Disconnected = 0x04, +} + +#[derive(Debug)] +#[repr(u8)] +enum Component { + Right = 0x02, + Left = 0x04, + Case = 0x08, +} + +impl BatteryPacket { + pub fn parse(raw: &[u8]) -> Option<Self> { + if !raw.starts_with(BATTERY_STATUS) || raw.len() <= 6 { + return None; + } + + let components = raw[6] as usize; + if components > 3 || raw.len() != 7 + 5 * components { + return None; + } + + let mut out = Self::default(); + for i in 0..components { + let i = 7 + (5 * i); + if raw[i + 1] != 0x01 || raw[i + 4] != 0x01 { + continue; + } + + let component_type = Component::from(raw[i])?; + let level = raw[i + 2]; + let status = BatteryStatus::from(raw[i + 3])?; + + if matches!(out.primary, Pod::None) { + out.primary = component_type.as_pod(); + } + + let component_status = Some(ComponentStatus { level, status }); + match component_type { + Component::Left => out.left = component_status, + Component::Right => out.right = component_status, + Component::Case => out.case = component_status, + } + } + + Some(out) + } + + pub fn as_arr(&self) -> [&Option<ComponentStatus>; 3] { + [&self.left, &self.right, &self.case] + } +} + +impl Component { + pub fn from(byte: u8) -> Option<Self> { + match byte { + 0x02 => Some(Self::Right), + 0x04 => Some(Self::Left), + 0x08 => Some(Self::Case), + _ => None, + } + } + + pub fn as_pod(&self) -> Pod { + match self { + Component::Left => Pod::Left, + Component::Right => Pod::Right, + Component::Case => Pod::None, + } + } +} + +impl BatteryStatus { + pub fn from(byte: u8) -> Option<Self> { + match byte { + 0x01 => Some(Self::Charging), + 0x02 => Some(Self::Discharging), + 0x04 => Some(Self::Disconnected), + _ => None, + } + } +} + +impl From<ComponentStatus> for status::ComponentStatus { + fn from(val: ComponentStatus) -> status::ComponentStatus { + status::ComponentStatus { + level: val.level, + status: val.status.into(), + } + } +} + +impl From<BatteryStatus> for status::BatteryStatus { + fn from(val: BatteryStatus) -> status::BatteryStatus { + match val { + BatteryStatus::Charging => status::BatteryStatus::Charging, + BatteryStatus::Discharging => status::BatteryStatus::Discharging, + BatteryStatus::Disconnected => status::BatteryStatus::Disconnected, + } + } +} diff --git a/plants-daemon/src/packets/in_ear.rs b/plants-daemon/src/packets/in_ear.rs @@ -0,0 +1,61 @@ +use common::status; + +use crate::{airpods_consts::EAR_DETECTION, packets::battery::Pod}; + +#[derive(Default, Debug)] +pub struct InEarPacket { + pub primary: EarStatus, + pub secondary: EarStatus, +} + +#[derive(Default, Debug, Clone, Copy)] +pub enum EarStatus { + InEar = 0x00, + NotInEar = 0x01, + InCase = 0x02, + #[default] + Disconnected, +} + +impl InEarPacket { + pub fn parse(bytes: &[u8]) -> Option<Self> { + if bytes.len() != 8 || !bytes.starts_with(EAR_DETECTION) { + return None; + } + + Some(InEarPacket { + primary: EarStatus::from(bytes[6]), + secondary: EarStatus::from(bytes[7]), + }) + } + + pub fn get(&self, primary: Pod) -> Option<[EarStatus; 2]> { + Some(match primary { + Pod::Left => [self.primary, self.secondary], + Pod::Right => [self.secondary, self.primary], + Pod::None => return None, + }) + } +} + +impl EarStatus { + pub fn from(byte: u8) -> Self { + match byte { + 0x00 => EarStatus::InEar, + 0x01 => EarStatus::NotInEar, + 0x02 => EarStatus::InCase, + _ => EarStatus::Disconnected, + } + } +} + +impl From<EarStatus> for status::EarStatus { + fn from(val: EarStatus) -> status::EarStatus { + match val { + EarStatus::InEar => status::EarStatus::InEar, + EarStatus::NotInEar => status::EarStatus::NotInEar, + EarStatus::InCase => status::EarStatus::InCase, + EarStatus::Disconnected => status::EarStatus::Disconnected, + } + } +} diff --git a/plants-daemon/src/packets/metadata.rs b/plants-daemon/src/packets/metadata.rs @@ -0,0 +1,49 @@ +use common::status; + +use crate::airpods_consts::METADATA; + +#[derive(Default, Debug)] +pub struct MetadataPacket { + pub device_name: String, + pub model_number: String, + pub manufacturer: String, +} + +impl MetadataPacket { + pub fn parse(data: &[u8]) -> Option<Self> { + if data.len() < 11 || !data.starts_with(METADATA) { + return None; + } + + let data = &data[11..]; + let mut start = 0; + let mut idx = 0; + + let mut read_string = || { + start = idx; + while idx < data.len() && data[idx] != 0x00 { + idx += 1; + } + + let out = String::from_utf8_lossy(&data[start..idx]).to_string(); + idx += 1; + + out + }; + + Some(Self { + device_name: read_string(), + model_number: read_string(), + manufacturer: read_string(), + }) + } +} + +impl From<MetadataPacket> for status::Metadata { + fn from(val: MetadataPacket) -> status::Metadata { + status::Metadata { + name: val.device_name, + model: val.model_number, + } + } +} diff --git a/plants-daemon/src/packets/mod.rs b/plants-daemon/src/packets/mod.rs @@ -0,0 +1,5 @@ +#![allow(dead_code)] + +pub mod battery; +pub mod in_ear; +pub mod metadata; diff --git a/plants-daemon/src/pbp.rs b/plants-daemon/src/pbp.rs @@ -0,0 +1,129 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Result; +use common::status::Status; +use tokio::time; +use zbus::object_server::InterfaceRef; + +use crate::daemon_impl::{PlantsDaemon, PlantsDaemonSignals}; + +pub async fn run(interface: InterfaceRef<PlantsDaemon>, state: Arc<Mutex<Status>>) -> Result<()> { + let mut session: Option<bluer::Session> = None; + + // Load config once to check for buds + let config = crate::config::load_config().await.ok(); + let target_mac = if let Some(c) = &config { + if let Some(buds) = &c.buds { + buds.mac.parse::<bluer::Address>().ok() + } else { + None + } + } else { + None + }; + + if target_mac.is_some() { + session = bluer::Session::new().await.ok(); + } + + loop { + let mut should_run = true; + + if let Some(mac) = target_mac { + should_run = false; + if let Some(sess) = &session { + // Check if device is connected with timeouts + let is_connected = async { + let Ok(Ok(adapter)) = + time::timeout(Duration::from_secs(2), sess.default_adapter()).await + else { + return false; + }; + + let Ok(device) = adapter.device(mac) else { + return false; + }; + + matches!( + time::timeout(Duration::from_secs(2), device.is_connected()).await, + Ok(Ok(true)) + ) + } + .await; + + if is_connected { + should_run = true; + } + } else if let Ok(Ok(s)) = + time::timeout(Duration::from_secs(2), bluer::Session::new()).await + { + session = Some(s); + } + } + + if should_run { + if let (Some(sess), Some(mac)) = (&session, target_mac) { + // Keep trying to stream as long as connected + if let Ok(adapter) = sess.default_adapter().await { + let res = crate::pbp_client::stream_pbp_stats( + sess, + &adapter, + bluer::Address(*mac), + { + let state = state.clone(); + let interface = interface.clone(); + move |new_status| { + { + let mut status = state.lock().unwrap(); + status.components = new_status.components; + status.ear = new_status.ear; + } + + let status = { + let status = state.lock().unwrap(); + status.clone() + }; + + // We need to spawn this because callback is sync but update is async + let interface = interface.clone(); + tokio::spawn(async move { + if let Err(e) = interface.update(status).await { + tracing::error!("Failed to update plants: {}", e); + } + }); + } + }, + ) + .await; + + if let Err(e) = res { + tracing::error!("PBP stream error: {}", e); + } + } + } + } else { + // If we are skipping, ensure we don't show stale info + { + let mut status = state.lock().unwrap(); + // Only clear if metadata is None (implying it might be PBP data). + if status.metadata.is_none() { + status.components = common::status::Components::default(); + status.ear = common::status::InEar::default(); + } + } + // Trigger update to clear PBP info from bar if present + let status = { + let status = state.lock().unwrap(); + status.clone() + }; + + if let Err(e) = interface.update(status).await { + tracing::error!("Failed to update plants: {}", e); + } + } + + // Wait before retrying (e.g. if disconnected or error) + time::sleep(Duration::from_secs(5)).await; + } +} diff --git a/plants-daemon/src/pbp_client.rs b/plants-daemon/src/pbp_client.rs @@ -0,0 +1,188 @@ +use anyhow::Result; +use bluer::rfcomm::{Profile, ProfileHandle, ReqError, Role, Stream}; +use bluer::{Adapter, Address, Device, Session}; +use common::status::{BatteryStatus, ComponentStatus, Components, EarStatus, Status}; +use futures::StreamExt; +use maestro::protocol::codec::Codec; +use maestro::protocol::utils; +use maestro::pwrpc::client::Client; +use maestro::service::MaestroService; +use std::time::Duration; + +pub async fn stream_pbp_stats<F>( + session: &Session, + adapter: &Adapter, + mac: Address, + callback: F, +) -> Result<()> +where + F: Fn(Status) + Send + Sync + 'static, +{ + let dev = adapter.device(mac)?; + tracing::debug!("Connecting to PBP RFCOMM at {}", mac); + + // Connect RFCOMM + let stream = connect_maestro_rfcomm(session, &dev).await?; + tracing::debug!("RFCOMM connected"); + + // Setup Codec + let codec = Codec::new(); + let stream = codec.wrap(stream); + + // Setup RPC Client + let mut client = Client::new(stream); + let handle = client.handle(); + + // Resolve channel first - pbpctrl does this before client.run() + let channel = utils::resolve_channel(&mut client).await?; + tracing::debug!("Maestro channel resolved: {}", channel); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let task = async move { + let mut service = MaestroService::new(handle, channel); + let mut call = service.subscribe_to_runtime_info()?; + tracing::debug!("Subscribed to RuntimeInfo"); + + let mut stream = call.stream(); + while let Some(msg) = stream.next().await { + let info = msg?; + tracing::trace!("Received RuntimeInfo update: {:?}", info); + if tx.send(info).await.is_err() { + break; + } + } + + Ok::<_, anyhow::Error>(()) + }; + + tokio::select! { + res = client.run() => { + tracing::warn!("Client run loop terminated: {:?}", res); + res?; + anyhow::bail!("client terminated unexpectedly"); + }, + res = task => { + tracing::warn!("Subscription task terminated: {:?}", res); + res?; + } + () = async { + while let Some(info) = rx.recv().await { + callback(runtime_info_to_status(info)); + } + } => {} + } + + Ok(()) +} + +fn runtime_info_to_status(info: maestro::protocol::types::RuntimeInfo) -> Status { + let mut components = Components::default(); + let mut ear = common::status::InEar::default(); + + // Map Case + if let Some(c) = info.battery_info.as_ref().and_then(|b| b.case.as_ref()) { + components.case = Some(ComponentStatus { + level: u8::try_from(c.level).unwrap_or(0), + status: if c.state == 2 { + BatteryStatus::Charging + } else { + BatteryStatus::Discharging + }, + }); + } + + // Map Left + if let Some(l) = info.battery_info.as_ref().and_then(|b| b.left.as_ref()) { + components.left = Some(ComponentStatus { + level: u8::try_from(l.level).unwrap_or(0), + status: if l.state == 2 { + BatteryStatus::Charging + } else { + BatteryStatus::Discharging + }, + }); + if let Some(placement) = &info.placement { + ear.left = if placement.left_bud_in_case { + EarStatus::InCase + } else { + EarStatus::InEar + }; + } + } + + // Map Right + if let Some(r) = info.battery_info.as_ref().and_then(|b| b.right.as_ref()) { + components.right = Some(ComponentStatus { + level: u8::try_from(r.level).unwrap_or(0), + status: if r.state == 2 { + BatteryStatus::Charging + } else { + BatteryStatus::Discharging + }, + }); + if let Some(placement) = &info.placement { + ear.right = if placement.right_bud_in_case { + EarStatus::InCase + } else { + EarStatus::InEar + }; + } + } + + Status { + metadata: None, + components, + ear, + devices: Vec::new(), + } +} + +async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result<Stream> { + let maestro_profile = Profile { + uuid: maestro::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + let mut handle = session.register_profile(maestro_profile).await?; + + let stream = tokio::try_join!( + try_connect_profile(dev), + handle_requests_for_profile(&mut handle, dev.address()), + )? + .1; + + Ok(stream) +} + +async fn try_connect_profile(dev: &Device) -> Result<()> { + const RETRY_TIMEOUT: Duration = Duration::from_secs(1); + const MAX_TRIES: u32 = 3; + + let mut i = 0; + while let Err(err) = dev.connect_profile(&maestro::UUID).await { + if i >= MAX_TRIES { + return Err(err.into()); + } + i += 1; + tokio::time::sleep(RETRY_TIMEOUT).await; + } + Ok(()) +} + +async fn handle_requests_for_profile( + handle: &mut ProfileHandle, + address: Address, +) -> Result<Stream> { + while let Some(req) = handle.next().await { + if req.device() == address { + return Ok(req.accept()?); + } + req.reject(ReqError::Rejected); + } + anyhow::bail!("profile terminated without requests") +} diff --git a/pmn/.gitignore b/pmn/.gitignore @@ -0,0 +1,2 @@ +.zig-cache +zig-out diff --git a/pmn/BUILD b/pmn/BUILD @@ -0,0 +1,19 @@ +load("@rules_zig//zig:defs.bzl", "zig_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +zig_binary( + name = "pmn", + srcs = glob(["src/*.zig"]), + main = "src/main.zig", + visibility = ["//visibility:public"], + zigopts = [ + "-O", + "ReleaseSafe", + "-Doptimize=ReleaseSafe", + ], +) + +local_deploy( + name = "deploy", + srcs = [":pmn"], +) diff --git a/pmn/README.md b/pmn/README.md @@ -0,0 +1,20 @@ +# pmn + +Mind-numbingly simple password selection utility for the `pass` password manager that works on Wayland, X11 and macOS. + +![](pmn.png) + +## Dependencies + +- [`fzf`](https://github.com/junegunn/fzf) - Command-line fuzzy finder. +- [`pass`](https://www.passwordstore.org/) - The standard unix password manager. +- [`pass-otp`](https://github.com/tadfisher/pass-otp) - pass extension for managing one-time-password tokens (optional). + +## Usage + +```bash +zig build -Doptimize=ReleaseSafe +./zig-out/bin/pmn +``` + +> Set `PASSWORD_STORE_DIR` if your password store is not at the default `~/.password-store` path. diff --git a/pmn/build.zig b/pmn/build.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "pmn", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + b.installArtifact(exe); +} diff --git a/pmn/build.zig.zon b/pmn/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .pmn, + .version = "0.1.0", + .fingerprint = 0xd1e9e8f723c1d143, + .minimum_zig_version = "0.15.2", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "README.md", + }, +} diff --git a/pmn/justfile b/pmn/justfile @@ -0,0 +1,22 @@ +targets := "x86_64-linux aarch64-linux x86_64-macos aarch64-macos" + +help: + @just --list + +build target="x86_64-linux" optimize="ReleaseSafe": + zig build -Dtarget={{ target }} -Doptimize={{ optimize }} --prefix zig-out/{{ target }} + +all optimize="ReleaseSafe": + @for target in {{ targets }}; do \ + echo "$target"; \ + just build $target {{ optimize }}; \ + done + +fmt: + zig fmt ./ + +lint: + zwanzig ./ + +clean: + rm -rf zig-out .zig-cache diff --git a/pmn/pmn.png b/pmn/pmn.png Binary files differ. diff --git a/pmn/src/main.zig b/pmn/src/main.zig @@ -0,0 +1,281 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const Entry = struct { + path: []const u8, + accessed: i128, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const pass_dir = try dir(alloc); + defer alloc.free(pass_dir); + + var passwords = std.ArrayListUnmanaged(Entry){}; + defer { + for (passwords.items) |p| alloc.free(p.path); + passwords.deinit(alloc); + } + + try collect(alloc, pass_dir, "", &passwords); + + std.mem.sort(Entry, passwords.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return a.accessed > b.accessed; + } + }.lessThan); + + var paths = std.ArrayListUnmanaged([]const u8){}; + defer paths.deinit(alloc); + for (passwords.items) |entry| { + try paths.append(alloc, entry.path); + } + + const selected = try fzf(alloc, paths.items) orelse return; + defer alloc.free(selected); + + const exists = blk: { + for (passwords.items) |item| { + if (std.mem.eql(u8, item.path, selected)) break :blk true; + } + break :blk false; + }; + const basename_start = if (std.mem.lastIndexOfScalar(u8, selected, '/')) |idx| idx + 1 else 0; + const has_otp = std.mem.indexOf(u8, selected[basename_start..], "otp") != null; + + const secret = if (has_otp and exists) + try run(alloc, &.{ "pass", "otp", "code", selected }) + else if (!exists) + try store(alloc, selected) + else + run(alloc, &.{ "pass", "show", selected }) catch try store(alloc, selected); + + try clip(alloc, secret); + alloc.free(secret); +} + +fn dir(alloc: std.mem.Allocator) ![]const u8 { + if (std.process.getEnvVarOwned(alloc, "PASSWORD_STORE_DIR")) |p| { + return p; + } else |_| {} + + const home = std.posix.getenv("HOME") orelse return error.NoHome; + return try std.fs.path.join(alloc, &.{ home, ".password-store" }); +} + +fn collect(alloc: std.mem.Allocator, base_dir: []const u8, sub_path: []const u8, list: *std.ArrayListUnmanaged(Entry)) anyerror!void { + const full_path = try std.fs.path.join(alloc, &.{ base_dir, sub_path }); + defer alloc.free(full_path); + + var d = std.fs.openDirAbsolute(full_path, .{ .iterate = true }) catch return; + defer d.close(); + + var it = d.iterate(); + while (try it.next()) |entry| { + if (entry.name.len > 0 and entry.name[0] == '.') continue; + + const entry_sub_path = if (sub_path.len == 0) + try alloc.dupe(u8, entry.name) + else + try std.fs.path.join(alloc, &.{ sub_path, entry.name }); + errdefer alloc.free(entry_sub_path); + + if (entry.kind == .directory) { + try collect(alloc, base_dir, entry_sub_path, list); + alloc.free(entry_sub_path); + } else if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".gpg")) { + const name = entry_sub_path[0 .. entry_sub_path.len - 4]; + const file_path = try std.fs.path.join(alloc, &.{ base_dir, entry_sub_path }); + defer alloc.free(file_path); + + const accessed = blk: { + const stat = std.fs.openFileAbsolute(file_path, .{}) catch |err| { + std.debug.print("Failed to stat {s}: {}\n", .{ file_path, err }); + break :blk @as(i128, 0); + }; + defer stat.close(); + + const file_stat = stat.stat() catch |err| { + std.debug.print("Failed to stat {s}: {}\n", .{ file_path, err }); + break :blk @as(i128, 0); + }; + + break :blk @as(i128, @intCast(file_stat.atime)); + }; + + try list.append(alloc, Entry{ + .path = try alloc.dupe(u8, name), + .accessed = accessed, + }); + alloc.free(entry_sub_path); + } else { + alloc.free(entry_sub_path); + } + } +} + +fn fzf(alloc: std.mem.Allocator, items: []const []const u8) !?[]u8 { + var child = std.process.Child.init(&.{ "fzf", "--print-query" }, alloc); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Inherit; + + try child.spawn(); + + if (child.stdin) |stdin| { + for (items) |item| { + try stdin.writeAll(item); + try stdin.writeAll("\n"); + } + stdin.close(); + child.stdin = null; + } + + const output = try read(alloc, &child); + defer alloc.free(output); + + const term = try child.wait(); + if (term != .Exited) return null; + + const trimmed = std.mem.trim(u8, output, " \n\r\t"); + if (trimmed.len == 0) return null; + + if (std.mem.indexOfScalar(u8, trimmed, '\n')) |idx| { + const selected = std.mem.trim(u8, trimmed[idx + 1 ..], " \n\r\t"); + if (selected.len > 0) return try alloc.dupe(u8, selected); + const query = std.mem.trim(u8, trimmed[0..idx], " \n\r\t"); + if (query.len > 0) return try alloc.dupe(u8, query); + } + + return try alloc.dupe(u8, trimmed); +} + +fn run(alloc: std.mem.Allocator, args: []const []const u8) ![]u8 { + var child = std.process.Child.init(args, alloc); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Inherit; + + try child.spawn(); + + const output = try read(alloc, &child); + + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) { + alloc.free(output); + return error.FailedExit; + } + + if (std.mem.indexOfScalar(u8, output, '\n')) |idx| { + const result = try alloc.dupe(u8, output[0..idx]); + alloc.free(output); + return result; + } + + return output; +} + +fn read(alloc: std.mem.Allocator, child: *std.process.Child) ![]u8 { + var stdout = std.ArrayListUnmanaged(u8){}; + defer stdout.deinit(alloc); + + if (child.stdout) |reader| { + var buffer: [1024]u8 = undefined; + while (true) { + const n = try reader.read(&buffer); + if (n == 0) break; + try stdout.appendSlice(alloc, buffer[0..n]); + } + } + + return try alloc.dupe(u8, stdout.items); +} + +fn gen(alloc: std.mem.Allocator) ![]u8 { + const charset = std.ascii.letters ++ "0123456789"; + const special_chars = "!@#$%^&*()-_=+[]{}|;:,.<>?"; + + var password = try alloc.alloc(u8, 16); + errdefer alloc.free(password); + + std.crypto.random.bytes(password); + for (password) |*c| { + c.* = charset[c.* % charset.len]; + } + + const pos = std.crypto.random.intRangeLessThan(usize, 0, password.len); + const idx = std.crypto.random.intRangeLessThan(usize, 0, special_chars.len); + password[pos] = special_chars[idx]; + + return password; +} + +fn store(alloc: std.mem.Allocator, name: []const u8) ![]u8 { + const password = try gen(alloc); + errdefer alloc.free(password); + + inner(alloc, name, password) catch |err| { + std.debug.print("Failed to store password: {}\n", .{err}); + return err; + }; + return password; +} + +fn inner(alloc: std.mem.Allocator, name: []const u8, password: []const u8) !void { + const pass_dir = try dir(alloc); + defer alloc.free(pass_dir); + + const full_path = try std.fs.path.join(alloc, &.{ pass_dir, name }); + defer alloc.free(full_path); + + if (std.fs.path.dirname(full_path)) |dir_path| { + std.fs.makeDirAbsolute(dir_path) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + } + + var child = std.process.Child.init(&.{ "pass", "insert", "-e", name }, alloc); + child.stdin_behavior = .Pipe; + child.stderr_behavior = .Inherit; + + try child.spawn(); + + if (child.stdin) |stdin| { + try stdin.writeAll(password); + try stdin.writeAll("\n"); + stdin.close(); + child.stdin = null; + } + + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) return error.FailedExit; +} + +fn clip(alloc: std.mem.Allocator, content: []const u8) !void { + const args: []const []const u8 = switch (builtin.os.tag) { + .macos => &.{"pbcopy"}, + .linux => args: { + if (std.process.getEnvVarOwned(alloc, "WAYLAND_DISPLAY")) |v| { + alloc.free(v); + break :args &.{"wl-copy"}; + } else |_| break :args &.{ "xclip", "-selection", "clipboard" }; + }, + else => return, + }; + + var child = std.process.Child.init(args, alloc); + child.stdin_behavior = .Pipe; + + try child.spawn(); + + if (child.stdin) |stdin| { + try stdin.writeAll(content); + stdin.close(); + child.stdin = null; + } + + const term = try child.wait(); + if (term != .Exited) return error.ClipboardFailed; +} diff --git a/pracomer/.gitignore b/pracomer/.gitignore @@ -0,0 +1,4 @@ +/.zig-cache/ +/result +/scratch +/zig-out/ diff --git a/pracomer/BUILD b/pracomer/BUILD @@ -0,0 +1,19 @@ +load("@rules_zig//zig:defs.bzl", "zig_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +zig_binary( + name = "pracomer", + main = "src/main.zig", + srcs = glob(["src/**/*.zig"]), + visibility = ["//visibility:public"], + zigopts = [ + "-O", + "ReleaseSafe", + "-Doptimize=ReleaseSafe", + ], +) + +local_deploy( + name = "deploy", + srcs = [":pracomer"], +) diff --git a/pracomer/build.zig b/pracomer/build.zig @@ -0,0 +1,66 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "pracomer", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/pracomer/build.zig.zon b/pracomer/build.zig.zon @@ -0,0 +1,86 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save <url>`, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .pracomer, + + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0x2a746e4d58465254, // Changing this has security and trust implications. + + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.15.2", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save <url>` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. If the contents of a URL change this will result in a hash mismatch + // // which will prevent zig from using it. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + // + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. Only files listed here will remain on disk + // when using the zig package manager. As a rule of thumb, one should list + // files required for compilation plus any license(s). + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/pracomer/src/command.zig b/pracomer/src/command.zig @@ -0,0 +1,85 @@ +const signals = @import("signals.zig"); +const std = @import("std"); +const terminal = @import("terminal.zig"); + +pub const Command = enum { + Pause, + Quit, + Reset, +}; + +const FdType = enum { stdin, signals }; + +pub const Poller = struct { + poller: std.io.Poller(FdType), + signals_file: std.fs.File, + /// Option set to the terminal, is null when running in non-interactive mode + terminal: ?terminal.Terminal, + + pub fn init(allocator: std.mem.Allocator) !Poller { + const opt_terminal = blk: { + break :blk terminal.Terminal.init() catch |err| { + std.log.debug("failed to init terminal: {any}", .{err}); + break :blk null; + }; + }; + errdefer { + if (opt_terminal) |terminal_| { + terminal_.deinit(); + } + } + + const signals_file = try signals.open(); + errdefer signals_file.close(); + + return Poller{ + .terminal = opt_terminal, + .signals_file = signals_file, + .poller = std.io.poll( + allocator, + FdType, + .{ + .stdin = std.fs.File.stdin(), + .signals = signals_file, + }, + ), + }; + } + + pub fn interactive(self: *Poller) bool { + return self.terminal != null; + } + + pub fn deinit(self: *Poller) void { + self.poller.deinit(); + if (self.terminal) |terminal_| terminal_.deinit(); + self.signals_file.close(); + } + + pub fn pollTimeout(self: *Poller, nanoseconds: u64) !?Command { + if (!try self.poller.pollTimeout(nanoseconds)) { + return null; + } + + if (self.poller.reader(.stdin).buffered().len > 0) { + const char = try self.poller.reader(.stdin).takeByte(); + switch (char) { + 'p' => return Command.Pause, + 'q' => return Command.Quit, + 'r' => return Command.Reset, + else => {}, + } + } + + if (self.poller.reader(.signals).buffered().len > 0) { + const signo = try signals.readSignal(self.poller.reader(.signals)); + switch (signo) { + std.posix.SIG.USR1 => return Command.Pause, + std.posix.SIG.USR2 => return Command.Reset, + else => unreachable, + } + } + + return null; + } +}; diff --git a/pracomer/src/main.zig b/pracomer/src/main.zig @@ -0,0 +1,161 @@ +const command = @import("command.zig"); +const options = @import("options.zig"); +const pausable_timer = @import("pausable_timer.zig"); +const std = @import("std"); +const builtin = @import("builtin"); +const step = @import("step.zig"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + + const program_and_args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, program_and_args); + + const program = program_and_args[0]; + const args = program_and_args[1..]; + + var stdout_buffer: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + + const opts = options.parseArgs(args) catch { + try printUsage(allocator, stdout, program); + std.process.exit(1); + }; + + switch (opts) { + .help => { + try printUsage(allocator, stdout, program); + }, + .settings => |settings| { + try run(allocator, stdout, settings); + }, + } +} + +fn run(allocator: std.mem.Allocator, out: *std.Io.Writer, settings: options.Settings) !void { + var poller = try command.Poller.init(allocator); + defer poller.deinit(); + + const status_file: ?std.fs.File = if (settings.file) |path| try std.fs.cwd().createFile(path, .{}) else null; + defer if (status_file) |f| f.close(); + defer if (settings.file) |path| std.fs.cwd().deleteFile(path) catch |err| { + std.log.debug("failed to delete status file: {any}", .{err}); + }; + + // Print a placeholder to be cleared by the first status + if (poller.interactive()) { + try out.print("\n", .{}); + try out.flush(); + } + + var current = step.Step{}; + while (true) { + var timer = try pausable_timer.PausableTimer.init(); + + var remaining = current.length(settings); + + while (remaining > 0) : ({ + remaining = current.length(settings) -| timer.read(); + }) { + // Clear the last status + if (poller.interactive()) { + try out.print("\x1b[F\x1b[2K\r", .{}); + try out.flush(); + } + try printStatus( + allocator, + out, + status_file, + current, + remaining, + ); + + if (try poller.pollTimeout(std.time.ns_per_s)) |command_| { + switch (command_) { + command.Command.Quit => { + try out.print("\n", .{}); + try out.flush(); + return; + }, + command.Command.Pause => { + try timer.togglePause(); + }, + command.Command.Reset => { + // if the step is not started yet + if (timer.paused and remaining == current.length(settings)) { + current = step.Step{}; + } + + try timer.reset(); + }, + } + } + } + + current = current.next(); + if (settings.notifications and current.length(settings) > 0) { + notify(allocator, current.step_type.message()); + } + } +} + +fn notify(allocator: std.mem.Allocator, message: []const u8) void { + if (builtin.os.tag == .macos) { + const script = std.fmt.allocPrint(allocator, "display notification \"{s}\" with title \"pracomer\"", .{message}) catch return; + defer allocator.free(script); + const argv = [_][]const u8{ "osascript", "-e", script }; + var proc = std.process.Child.init(&argv, allocator); + _ = proc.spawn() catch return; + _ = proc.wait() catch |err| { + std.log.debug("failed to wait for process: {any}", .{err}); + }; + } else { + const argv = [_][]const u8{ "notify-send", "-u", "critical", message }; + var proc = std.process.Child.init(&argv, allocator); + _ = proc.spawn() catch return; + _ = proc.wait() catch |err| { + std.log.debug("failed to wait for process: {any}", .{err}); + }; + } +} + +fn printStatus( + allocator: std.mem.Allocator, + out: *std.Io.Writer, + status_file: ?std.fs.File, + current: step.Step, + remaining: u64, +) !void { + const msg = try current.render(allocator, remaining); + defer allocator.free(msg); + + try out.print("{s}\n", .{msg}); + try out.flush(); + + if (status_file) |f| { + try f.seekTo(0); + try f.writeAll(msg); + try f.writeAll("\n"); + try f.sync(); + } +} + +fn printUsage( + allocator: std.mem.Allocator, + out: *std.Io.Writer, + program: []const u8, +) !void { + const usage = try options.usage(allocator, program); + defer allocator.free(usage); + + try out.print("{s}", .{usage}); + try out.flush(); +} + +test "main.zig" { + std.testing.refAllDecls(@This()); +} diff --git a/pracomer/src/options.zig b/pracomer/src/options.zig @@ -0,0 +1,92 @@ +const std = @import("std"); + +pub const OptionsTag = enum { settings, help }; + +pub const Options = union(OptionsTag) { + settings: Settings, + help: void, +}; + +pub const Settings = struct { + /// Length of a task in minutes + task_length: u32 = 26, + /// Length of a break in minutes + task_break: u32 = 6, + /// Enable notifications + notifications: bool = true, + /// Write output to a file + file: ?[]const u8 = null, +}; + +pub fn parseArgs(args: []const []const u8) !Options { + var settings = Settings{}; + + var i: u32 = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + // settings options + if (i + 1 < args.len and std.mem.eql(u8, arg, "-t")) { + i += 1; + settings.task_length = try std.fmt.parseInt(u32, args[i], 10); + } else if (i + 1 < args.len and std.mem.eql(u8, arg, "-b")) { + i += 1; + settings.task_break = try std.fmt.parseInt(u32, args[i], 10); + } else if (std.mem.eql(u8, arg, "-s")) { + settings.notifications = false; + } else if (i + 1 < args.len and std.mem.eql(u8, arg, "--file")) { + i += 1; + settings.file = args[i]; + } + // help + else if (std.mem.eql(u8, arg, "-h")) { + return .{ .help = void{} }; + } + // error + else { + return error.InvalidArgument; + } + } + + return .{ .settings = settings }; +} + +pub fn usage(allocator: std.mem.Allocator, program: []const u8) ![]u8 { + const result = try std.fmt.allocPrint(allocator, + \\Usage: {s} + \\[-t <task_length>] Task length in minutes (default: 52 mins) + \\[-b <task_break>] Task break length in minutes (default: 17 mins) + \\[-s] Disable notifications + \\[--file <path>] Write status to a file + \\[-h] Show this help message + \\ + , .{program}); + + return result; +} + +test "parseArgs options" { + const args = [_][]const u8{ + "-t", "30", + "-b", "20", + "-s", + }; + + const options = try parseArgs(&args); + try std.testing.expectEqual(options.settings.task_length, 30); + try std.testing.expectEqual(options.settings.task_break, 20); + try std.testing.expectEqual(options.settings.notifications, false); +} + +test "parseArgs help" { + const args = [_][]const u8{"-h"}; + + const options = try parseArgs(&args); + try std.testing.expectEqual(options, OptionsTag.help); +} + +test "parseArgs nok" { + const args = [_][]const u8{ "some", "junk", "arguments" }; + + const result = parseArgs(&args); + try std.testing.expectError(error.InvalidArgument, result); +} diff --git a/pracomer/src/pausable_timer.zig b/pracomer/src/pausable_timer.zig @@ -0,0 +1,43 @@ +const std = @import("std"); + +pub const PausableTimer = struct { + timer: std.time.Timer, + elapsed_before_pause: u64, + paused: bool, + + pub fn init() !PausableTimer { + return PausableTimer{ + .timer = try std.time.Timer.start(), + .elapsed_before_pause = 0, + .paused = true, + }; + } + + pub fn togglePause(self: *PausableTimer) !void { + if (self.paused) { + // Resuming + self.timer.reset(); + self.paused = false; + } else { + // Pausing + self.elapsed_before_pause += self.timer.read(); + self.paused = true; + } + } + + pub fn reset(self: *PausableTimer) !void { + self.elapsed_before_pause = 0; + self.timer.reset(); + self.paused = true; + } + + pub fn read(self: *PausableTimer) u64 { + if (self.paused) { + return self.elapsed_before_pause; + } else { + // In Zig, timer.read() returns the time since the last reset() or lap() + // and does NOT update the internal 'previous' field. + return self.elapsed_before_pause + self.timer.read(); + } + } +}; diff --git a/pracomer/src/signals.zig b/pracomer/src/signals.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +var signal_pipe: [2]std.posix.fd_t = undefined; + +fn signalHandler(sig: i32) callconv(.c) void { + const s: i32 = sig; + _ = std.posix.write(signal_pipe[1], std.mem.asBytes(&s)) catch |err| { + std.log.debug("failed to write to signal pipe: {any}", .{err}); + }; +} + +pub fn open() !std.fs.File { + if (builtin.os.tag == .linux) { + var mask = std.posix.sigemptyset(); + std.posix.sigaddset(&mask, std.posix.SIG.USR1); + std.posix.sigaddset(&mask, std.posix.SIG.USR2); + + // Block signals so they can be handled by signalfd + _ = std.posix.sigprocmask(std.posix.SIG.BLOCK, &mask, null); + + const fd = try std.posix.signalfd(-1, &mask, std.os.linux.SFD.NONBLOCK); + return .{ .handle = fd }; + } else { + // macOS / BSD / etc. use self-pipe + signal_pipe = try std.posix.pipe(); + + // Set up signal handlers + const sa = std.posix.Sigaction{ + .handler = .{ .handler = signalHandler }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + + std.posix.sigaction(std.posix.SIG.USR1, &sa, null); + std.posix.sigaction(std.posix.SIG.USR2, &sa, null); + + // Return the read end of the pipe + return .{ .handle = signal_pipe[0] }; + } +} + +pub fn readSignal(reader: anytype) !i32 { + if (builtin.os.tag == .linux) { + const siginfo = try reader.takeStruct(std.os.linux.signalfd_siginfo, .little); + return @intCast(siginfo.signo); + } else { + // Read the signal number from the pipe + return try reader.takeInt(i32, .little); + } +} diff --git a/pracomer/src/step.zig b/pracomer/src/step.zig @@ -0,0 +1,57 @@ +const options = @import("options.zig"); +const std = @import("std"); + +pub const StepType = enum(u8) { + task = 'T', + task_break = 'B', + + pub fn message(self: StepType) []const u8 { + return switch (self) { + StepType.task => "work", + StepType.task_break => "break", + }; + } +}; + +pub const Step = struct { + step_type: StepType = StepType.task, + + pub fn next(self: Step) Step { + return switch (self.step_type) { + StepType.task => Step{ + .step_type = StepType.task_break, + }, + StepType.task_break => Step{ + .step_type = StepType.task, + }, + }; + } + + pub fn length(self: Step, settings: options.Settings) u64 { + const minutes: u64 = switch (self.step_type) { + StepType.task => settings.task_length, + StepType.task_break => settings.task_break, + }; + + return minutes * std.time.ns_per_min; + } + + pub fn render( + self: Step, + allocator: std.mem.Allocator, + countdown: u64, + ) ![]u8 { + _ = self; + const total_secs = countdown / std.time.ns_per_s; + const mins = total_secs / std.time.s_per_min; + const secs = total_secs % std.time.s_per_min; + return try std.fmt.allocPrint( + allocator, + "{:0>2}:{:0>2}", + .{ + mins, + secs, + }, + ); + } +}; diff --git a/pracomer/src/terminal.zig b/pracomer/src/terminal.zig @@ -0,0 +1,28 @@ +const std = @import("std"); + +/// Turn on these two options on stdin: +/// - ICANON: disable line buffering +/// - ECHO: disable echo +/// +/// Used to be able to read a single character from stdin. Only works when +/// running in interactive mode +pub const Terminal = struct { + termios: std.posix.termios, + + pub fn init() !Terminal { + const old = try std.posix.tcgetattr(std.posix.STDIN_FILENO); + var termios = old; + // unbuffered input + termios.lflag.ICANON = false; + // no echo + termios.lflag.ECHO = false; + try std.posix.tcsetattr(std.posix.STDIN_FILENO, .NOW, termios); + return .{ .termios = old }; + } + + pub fn deinit(self: Terminal) void { + std.posix.tcsetattr(std.posix.STDIN_FILENO, .NOW, self.termios) catch |err| { + std.log.debug("failed to restore terminal attributes: {any}", .{err}); + }; + } +}; diff --git a/requirements.in b/requirements.in @@ -0,0 +1,8 @@ +fire==0.7.1 +numpy==2.4.1 +pillow==12.1.0 +paramiko==4.0.0 +mutagen==1.47.0 +requests==2.32.5 +python-crontab==3.3.0 +scipy==1.17.1 diff --git a/requirements.out b/requirements.out @@ -0,0 +1,633 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# bazel run //:requirements.update +# +bcrypt==5.0.0 \ + --hash=sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4 \ + --hash=sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a \ + --hash=sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464 \ + --hash=sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4 \ + --hash=sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746 \ + --hash=sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2 \ + --hash=sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41 \ + --hash=sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd \ + --hash=sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9 \ + --hash=sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e \ + --hash=sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538 \ + --hash=sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10 \ + --hash=sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb \ + --hash=sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef \ + --hash=sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4 \ + --hash=sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23 \ + --hash=sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef \ + --hash=sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75 \ + --hash=sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42 \ + --hash=sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a \ + --hash=sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172 \ + --hash=sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683 \ + --hash=sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2 \ + --hash=sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4 \ + --hash=sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba \ + --hash=sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da \ + --hash=sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493 \ + --hash=sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254 \ + --hash=sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534 \ + --hash=sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f \ + --hash=sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c \ + --hash=sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c \ + --hash=sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83 \ + --hash=sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff \ + --hash=sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d \ + --hash=sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861 \ + --hash=sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5 \ + --hash=sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9 \ + --hash=sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b \ + --hash=sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac \ + --hash=sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e \ + --hash=sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f \ + --hash=sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb \ + --hash=sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86 \ + --hash=sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980 \ + --hash=sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd \ + --hash=sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d \ + --hash=sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1 \ + --hash=sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911 \ + --hash=sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993 \ + --hash=sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191 \ + --hash=sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4 \ + --hash=sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2 \ + --hash=sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8 \ + --hash=sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db \ + --hash=sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927 \ + --hash=sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be \ + --hash=sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb \ + --hash=sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e \ + --hash=sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf \ + --hash=sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd \ + --hash=sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822 \ + --hash=sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b + # via paramiko +certifi==2026.1.4 \ + --hash=sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c \ + --hash=sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120 + # via requests +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + # via + # cryptography + # pynacl +charset-normalizer==3.4.4 \ + --hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \ + --hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \ + --hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \ + --hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \ + --hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \ + --hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \ + --hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \ + --hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \ + --hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \ + --hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \ + --hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \ + --hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \ + --hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \ + --hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \ + --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ + --hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \ + --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ + --hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \ + --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ + --hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \ + --hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \ + --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ + --hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \ + --hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \ + --hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \ + --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ + --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ + --hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \ + --hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \ + --hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \ + --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ + --hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \ + --hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \ + --hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \ + --hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \ + --hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \ + --hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \ + --hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \ + --hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \ + --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ + --hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \ + --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ + --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ + --hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \ + --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ + --hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \ + --hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \ + --hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \ + --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ + --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ + --hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \ + --hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \ + --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ + --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ + --hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \ + --hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \ + --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ + --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ + --hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \ + --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \ + --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ + --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ + --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ + --hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \ + --hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \ + --hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \ + --hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \ + --hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \ + --hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \ + --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ + --hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \ + --hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \ + --hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \ + --hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \ + --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ + --hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \ + --hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \ + --hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \ + --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ + --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ + --hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \ + --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ + --hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \ + --hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \ + --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ + --hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \ + --hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \ + --hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \ + --hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \ + --hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \ + --hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \ + --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ + --hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \ + --hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \ + --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ + --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ + --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ + --hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \ + --hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \ + --hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \ + --hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \ + --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ + --hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \ + --hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \ + --hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \ + --hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \ + --hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \ + --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ + --hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \ + --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \ + --hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \ + --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \ + --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608 + # via requests +cryptography==46.0.3 \ + --hash=sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217 \ + --hash=sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d \ + --hash=sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc \ + --hash=sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71 \ + --hash=sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971 \ + --hash=sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a \ + --hash=sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926 \ + --hash=sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc \ + --hash=sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d \ + --hash=sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b \ + --hash=sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20 \ + --hash=sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044 \ + --hash=sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3 \ + --hash=sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715 \ + --hash=sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4 \ + --hash=sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506 \ + --hash=sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f \ + --hash=sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0 \ + --hash=sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683 \ + --hash=sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3 \ + --hash=sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21 \ + --hash=sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91 \ + --hash=sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c \ + --hash=sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8 \ + --hash=sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df \ + --hash=sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c \ + --hash=sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb \ + --hash=sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7 \ + --hash=sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04 \ + --hash=sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db \ + --hash=sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459 \ + --hash=sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea \ + --hash=sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914 \ + --hash=sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717 \ + --hash=sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9 \ + --hash=sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac \ + --hash=sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32 \ + --hash=sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec \ + --hash=sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1 \ + --hash=sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb \ + --hash=sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac \ + --hash=sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665 \ + --hash=sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e \ + --hash=sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb \ + --hash=sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5 \ + --hash=sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936 \ + --hash=sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de \ + --hash=sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372 \ + --hash=sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54 \ + --hash=sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422 \ + --hash=sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849 \ + --hash=sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c \ + --hash=sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963 \ + --hash=sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018 + # via paramiko +fire==0.7.1 \ + --hash=sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf \ + --hash=sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882 + # via -r requirements.in +idna==3.11 \ + --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ + --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 + # via requests +invoke==2.2.1 \ + --hash=sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8 \ + --hash=sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707 + # via paramiko +mutagen==1.47.0 \ + --hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \ + --hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719 + # via -r requirements.in +numpy==2.4.1 \ + --hash=sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c \ + --hash=sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba \ + --hash=sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5 \ + --hash=sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8 \ + --hash=sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0 \ + --hash=sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d \ + --hash=sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574 \ + --hash=sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696 \ + --hash=sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5 \ + --hash=sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505 \ + --hash=sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0 \ + --hash=sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162 \ + --hash=sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844 \ + --hash=sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205 \ + --hash=sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4 \ + --hash=sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc \ + --hash=sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d \ + --hash=sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93 \ + --hash=sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01 \ + --hash=sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c \ + --hash=sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f \ + --hash=sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33 \ + --hash=sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82 \ + --hash=sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2 \ + --hash=sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42 \ + --hash=sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509 \ + --hash=sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a \ + --hash=sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e \ + --hash=sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556 \ + --hash=sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a \ + --hash=sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510 \ + --hash=sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295 \ + --hash=sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73 \ + --hash=sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3 \ + --hash=sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9 \ + --hash=sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8 \ + --hash=sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745 \ + --hash=sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2 \ + --hash=sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02 \ + --hash=sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d \ + --hash=sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344 \ + --hash=sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f \ + --hash=sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be \ + --hash=sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425 \ + --hash=sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1 \ + --hash=sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2 \ + --hash=sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2 \ + --hash=sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb \ + --hash=sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9 \ + --hash=sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15 \ + --hash=sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690 \ + --hash=sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0 \ + --hash=sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261 \ + --hash=sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a \ + --hash=sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc \ + --hash=sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f \ + --hash=sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5 \ + --hash=sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df \ + --hash=sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9 \ + --hash=sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2 \ + --hash=sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8 \ + --hash=sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426 \ + --hash=sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b \ + --hash=sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87 \ + --hash=sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220 \ + --hash=sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b \ + --hash=sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3 \ + --hash=sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e \ + --hash=sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501 \ + --hash=sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee \ + --hash=sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7 \ + --hash=sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c + # via + # -r requirements.in + # scipy +paramiko==4.0.0 \ + --hash=sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9 \ + --hash=sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f + # via -r requirements.in +pillow==12.1.0 \ + --hash=sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d \ + --hash=sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc \ + --hash=sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84 \ + --hash=sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de \ + --hash=sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0 \ + --hash=sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef \ + --hash=sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4 \ + --hash=sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82 \ + --hash=sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9 \ + --hash=sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030 \ + --hash=sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0 \ + --hash=sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18 \ + --hash=sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a \ + --hash=sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef \ + --hash=sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b \ + --hash=sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6 \ + --hash=sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179 \ + --hash=sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e \ + --hash=sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72 \ + --hash=sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64 \ + --hash=sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451 \ + --hash=sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd \ + --hash=sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924 \ + --hash=sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616 \ + --hash=sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a \ + --hash=sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94 \ + --hash=sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc \ + --hash=sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8 \ + --hash=sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9 \ + --hash=sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91 \ + --hash=sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a \ + --hash=sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c \ + --hash=sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670 \ + --hash=sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea \ + --hash=sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91 \ + --hash=sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c \ + --hash=sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc \ + --hash=sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0 \ + --hash=sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b \ + --hash=sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65 \ + --hash=sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661 \ + --hash=sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19 \ + --hash=sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1 \ + --hash=sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0 \ + --hash=sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e \ + --hash=sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75 \ + --hash=sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4 \ + --hash=sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8 \ + --hash=sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd \ + --hash=sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7 \ + --hash=sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61 \ + --hash=sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51 \ + --hash=sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551 \ + --hash=sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45 \ + --hash=sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1 \ + --hash=sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644 \ + --hash=sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796 \ + --hash=sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587 \ + --hash=sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304 \ + --hash=sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b \ + --hash=sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8 \ + --hash=sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17 \ + --hash=sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171 \ + --hash=sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3 \ + --hash=sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7 \ + --hash=sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988 \ + --hash=sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a \ + --hash=sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0 \ + --hash=sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c \ + --hash=sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2 \ + --hash=sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14 \ + --hash=sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5 \ + --hash=sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a \ + --hash=sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377 \ + --hash=sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0 \ + --hash=sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5 \ + --hash=sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b \ + --hash=sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d \ + --hash=sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac \ + --hash=sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c \ + --hash=sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554 \ + --hash=sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643 \ + --hash=sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13 \ + --hash=sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09 \ + --hash=sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208 \ + --hash=sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda \ + --hash=sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea \ + --hash=sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e \ + --hash=sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0 \ + --hash=sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831 \ + --hash=sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd + # via -r requirements.in +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pynacl==1.6.2 \ + --hash=sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c \ + --hash=sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574 \ + --hash=sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4 \ + --hash=sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130 \ + --hash=sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b \ + --hash=sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590 \ + --hash=sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444 \ + --hash=sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634 \ + --hash=sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87 \ + --hash=sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa \ + --hash=sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594 \ + --hash=sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0 \ + --hash=sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e \ + --hash=sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c \ + --hash=sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0 \ + --hash=sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c \ + --hash=sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577 \ + --hash=sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145 \ + --hash=sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88 \ + --hash=sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14 \ + --hash=sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6 \ + --hash=sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465 \ + --hash=sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0 \ + --hash=sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2 \ + --hash=sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9 + # via paramiko +python-crontab==3.3.0 \ + --hash=sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b \ + --hash=sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884 + # via -r requirements.in +requests==2.32.5 \ + --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ + --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf + # via -r requirements.in +scipy==1.17.1 \ + --hash=sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0 \ + --hash=sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458 \ + --hash=sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118 \ + --hash=sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39 \ + --hash=sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e \ + --hash=sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6 \ + --hash=sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec \ + --hash=sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21 \ + --hash=sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1 \ + --hash=sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6 \ + --hash=sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce \ + --hash=sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8 \ + --hash=sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448 \ + --hash=sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19 \ + --hash=sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b \ + --hash=sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87 \ + --hash=sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4 \ + --hash=sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9 \ + --hash=sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b \ + --hash=sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082 \ + --hash=sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464 \ + --hash=sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87 \ + --hash=sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c \ + --hash=sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369 \ + --hash=sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad \ + --hash=sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f \ + --hash=sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c \ + --hash=sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475 \ + --hash=sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd \ + --hash=sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866 \ + --hash=sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d \ + --hash=sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6 \ + --hash=sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb \ + --hash=sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca \ + --hash=sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0 \ + --hash=sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca \ + --hash=sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d \ + --hash=sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee \ + --hash=sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4 \ + --hash=sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717 \ + --hash=sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49 \ + --hash=sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2 \ + --hash=sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a \ + --hash=sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350 \ + --hash=sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950 \ + --hash=sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b \ + --hash=sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086 \ + --hash=sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444 \ + --hash=sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068 \ + --hash=sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff \ + --hash=sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a \ + --hash=sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50 \ + --hash=sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696 \ + --hash=sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21 \ + --hash=sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c \ + --hash=sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484 \ + --hash=sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118 \ + --hash=sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3 \ + --hash=sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea \ + --hash=sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293 \ + --hash=sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76 + # via -r requirements.in +termcolor==3.3.0 \ + --hash=sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5 \ + --hash=sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5 + # via fire +urllib3==2.6.3 \ + --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ + --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + # via requests diff --git a/rustfmt.toml b/rustfmt.toml @@ -0,0 +1,2 @@ +indent_style = "Block" +reorder_imports = false diff --git a/shirt_linkener/.gitignore b/shirt_linkener/.gitignore @@ -0,0 +1,4 @@ +/.expert/ +/_build/ +/deps/ +khal_notifier diff --git a/shirt_linkener/BUILD b/shirt_linkener/BUILD @@ -0,0 +1,13 @@ +load("//bazel:local-deploy.bzl", "local_deploy") + +# you have to `mix escript.build` first and then copy the binary to /opt/tools +filegroup( + name = "binary", + srcs = ["shirt_linkener"], + visibility = ["//visibility:public"], +) + +local_deploy( + name = "deploy", + srcs = [":binary"], +) diff --git a/shirt_linkener/Containerfile b/shirt_linkener/Containerfile @@ -0,0 +1,20 @@ +FROM docker.io/elixir:1.19-slim + +ENV MIX_ENV=prod +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/ + +WORKDIR /app +COPY mix.exs mix.lock ./ + +RUN mix local.hex --force && mix local.rebar --force && MIX_ENV=prod mix deps.get --only prod + +COPY config ./config +COPY lib ./lib + +RUN MIX_ENV=prod mix compile + +CMD ["mix", "run", "--no-halt"] diff --git a/shirt_linkener/Makefile b/shirt_linkener/Makefile @@ -0,0 +1,32 @@ +.PHONY: all build format lint check clean run + +AUTH_TOKEN := demo +BASE_URL := http://localhost +PORT := 4000 +DB_PATH := shortener.db + +export AUTH_TOKEN +export BASE_URL +export PORT +export DB_PATH + +all: build + +build: + mix deps.get + mix compile + +run: + mix run --no-halt + +format: + mix format + +lint: + mix credo --strict + +check: + mix dialyzer + +clean: + mix clean diff --git a/shirt_linkener/config/config.exs b/shirt_linkener/config/config.exs @@ -0,0 +1,7 @@ +import Config + +config :shortener, + port: System.get_env("PORT", "4000") |> String.to_integer(), + auth_token: System.get_env("AUTH_TOKEN", "changeme"), + db_path: System.get_env("DB_PATH", "data.db"), + base_url: System.get_env("BASE_URL", "http://localhost") diff --git a/shirt_linkener/config/runtime.exs b/shirt_linkener/config/runtime.exs @@ -0,0 +1,10 @@ +import Config + +if config_env() == :prod do + config :shortener, + port: System.get_env("PORT") |> then(&(if &1, do: String.to_integer(&1), else: 4000)), + host: System.get_env("HOST") || "127.0.0.1", + auth_token: System.get_env("AUTH_TOKEN") || "changeme", + db_path: System.get_env("DB_PATH") || "data.db", + base_url: System.get_env("BASE_URL") || "http://localhost" +end diff --git a/shirt_linkener/deploy b/shirt_linkener/deploy @@ -0,0 +1,2 @@ +#!/bin/bash +podman build -t shirt_linkener . && podman save shirt_linkener | ssh clam "sudo podman load && sudo systemctl restart shirt-linkener" diff --git a/shirt_linkener/lib/shortener/application.ex b/shirt_linkener/lib/shortener/application.ex @@ -0,0 +1,43 @@ +defmodule Shortener.Application do + @moduledoc """ + Shortener application entry point. + Starts the database and the HTTP server (Bandit). + """ + use Application + require Logger + + @impl true + def start(_type, _args) do + port = Application.fetch_env!(:shortener, :port) + host = Application.fetch_env!(:shortener, :host) + db_path = Application.fetch_env!(:shortener, :db_path) + + children = [ + {Shortener.DB, db_path}, + {Bandit, plug: Shortener.Router, port: port, ip: parse_ip(host)} + ] + + Logger.info("Starting shortener on #{host}:#{port}") + + Supervisor.start_link(children, strategy: :one_for_one, name: Shortener.Supervisor) + end + + @impl true + def prep_stop(state) do + Logger.info("Shortener application is preparing to stop") + state + end + + @impl true + def stop(_state) do + Logger.info("Shortener application stopped") + :ok + end + + defp parse_ip(ip_str) do + case :inet.parse_address(to_charlist(ip_str)) do + {:ok, ip} -> ip + _ -> {127, 0, 0, 1} + end + end +end diff --git a/shirt_linkener/lib/shortener/db.ex b/shirt_linkener/lib/shortener/db.ex @@ -0,0 +1,81 @@ +defmodule Shortener.DB do + @moduledoc """ + SQLite database interface using Exqlite. + Stores and retrieves short links. + """ + + use GenServer + require Logger + + def start_link(db_path) do + GenServer.start_link(__MODULE__, db_path, name: __MODULE__) + end + + @impl true + def init(db_path) do + Process.flag(:trap_exit, true) + {:ok, conn} = Exqlite.Sqlite3.open(db_path) + + :ok = + Exqlite.Sqlite3.execute(conn, """ + CREATE TABLE IF NOT EXISTS shirts ( + code TEXT PRIMARY KEY, + url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + Logger.info("Database ready at #{db_path}") + {:ok, conn} + end + + @doc "Insert a new short code → url mapping. Returns :ok or {:error, reason}." + def insert(code, url) do + GenServer.call(__MODULE__, {:insert, code, url}) + end + + @doc "Look up a URL by short code. Returns {:ok, url} or :not_found." + def lookup(code) do + GenServer.call(__MODULE__, {:lookup, code}) + end + + # --- server callbacks --- + + @impl true + def handle_call({:insert, code, url}, _from, conn) do + result = + with {:ok, stmt} <- + Exqlite.Sqlite3.prepare(conn, "INSERT INTO shirts (code, url) VALUES (?1, ?2)"), + :ok <- Exqlite.Sqlite3.bind(stmt, [code, url]), + :done <- Exqlite.Sqlite3.step(conn, stmt), + :ok <- Exqlite.Sqlite3.release(conn, stmt) do + :ok + else + {:error, reason} -> {:error, reason} + end + + {:reply, result, conn} + end + + def handle_call({:lookup, code}, _from, conn) do + result = + with {:ok, stmt} <- + Exqlite.Sqlite3.prepare(conn, "SELECT url FROM shirts WHERE code = ?1"), + :ok <- Exqlite.Sqlite3.bind(stmt, [code]), + {:row, [url]} <- Exqlite.Sqlite3.step(conn, stmt), + :ok <- Exqlite.Sqlite3.release(conn, stmt) do + {:ok, url} + else + :done -> :not_found + {:error, reason} -> {:error, reason} + end + + {:reply, result, conn} + end + + @impl true + def terminate(_reason, conn) do + Logger.info("Closing database connection") + Exqlite.Sqlite3.close(conn) + end +end diff --git a/shirt_linkener/lib/shortener/router.ex b/shirt_linkener/lib/shortener/router.ex @@ -0,0 +1,96 @@ +defmodule Shortener.Router do + @moduledoc """ + Plug router: + POST /curtail — create a short link (requires Bearer token) + GET /s/:code — redirect to the original URL + """ + + use Plug.Router + + plug Plug.Logger + plug :match + plug Plug.Parsers, parsers: [:json], json_decoder: Jason + plug :dispatch + + # --------------------------------------------------------------------------- + # POST /l/curtail + # Body (JSON): {"url": "https://example.com/very/long/path"} + # Optional: {"url": "...", "code": "my-custom-code"} + # Header: Authorization: Bearer <token> + # --------------------------------------------------------------------------- + post "/curtail" do + with :ok <- check_auth(conn), + {:ok, url} <- fetch_url(conn), + code <- Map.get(conn.body_params, "code") || random_code(), + :ok <- Shortener.DB.insert(code, url) do + base_url = Application.fetch_env!(:shortener, :base_url) + short_url = "#{base_url}/s/#{code}" + + conn + |> put_resp_content_type("application/json") + |> send_resp(201, Jason.encode!(%{short_url: short_url, code: code, url: url})) + else + {:error, :unauthorized} -> + json(conn, 401, %{error: "unauthorized"}) + + {:error, :missing_url} -> + json(conn, 422, %{error: "missing required field: url"}) + + {:error, reason} -> + json(conn, 409, %{error: "could not save link: #{inspect(reason)}"}) + end + end + + # --------------------------------------------------------------------------- + # GET /s/:code — redirect + # --------------------------------------------------------------------------- + get "/s/:code" do + case Shortener.DB.lookup(code) do + {:ok, url} -> + conn + |> put_resp_header("location", url) + |> send_resp(301, "") + + :not_found -> + json(conn, 404, %{error: "not found"}) + end + end + + # --------------------------------------------------------------------------- + # Catch-all + # --------------------------------------------------------------------------- + match _ do + json(conn, 404, %{error: "not found"}) + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp check_auth(conn) do + expected = "Bearer #{Application.fetch_env!(:shortener, :auth_token)}" + + case get_req_header(conn, "authorization") do + [^expected] -> :ok + _ -> {:error, :unauthorized} + end + end + + defp fetch_url(conn) do + case conn.body_params do + %{"url" => url} when is_binary(url) and url != "" -> {:ok, url} + _ -> {:error, :missing_url} + end + end + + defp random_code do + :crypto.strong_rand_bytes(6) + |> Base.url_encode64(padding: false) + end + + defp json(conn, status, body) do + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end +end diff --git a/shirt_linkener/mix.exs b/shirt_linkener/mix.exs @@ -0,0 +1,31 @@ +defmodule Shortener.MixProject do + use Mix.Project + + def project do + [ + app: :shortener, + version: "0.0.1", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Shortener.Application, []} + ] + end + + defp deps do + [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:bandit, "~> 1.10"}, + {:plug, "~> 1.19"}, + {:exqlite, "~> 0.35"}, + {:jason, "~> 1.4"} + ] + end +end diff --git a/shirt_linkener/mix.lock b/shirt_linkener/mix.lock @@ -0,0 +1,27 @@ +%{ + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "burrito": {:hex, :burrito, "1.5.0", "d68ec01df2871f1d5bc603b883a78546c75761ac73c1bec1b7ae2cc74790fcd1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "3861abda7bffa733862b48da3e03df0b4cd41abf6fd24b91745f5c16d971e5fa"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, +} diff --git a/shuffle/BUILD b/shuffle/BUILD @@ -0,0 +1,15 @@ +load("@rules_python//python:defs.bzl", "py_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +py_binary( + name = "shuffle", + srcs = ["shuffle.py"], + main = "shuffle.py", + python_version = "PY3", +) + +local_deploy( + name = "deploy", + srcs = [":shuffle"], + copy_runfiles = True, +) diff --git a/shuffle/shuffle.py b/shuffle/shuffle.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import argparse +import random +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description="picks and plays random albums in mpd") + _ = parser.add_argument("file", type=Path) + _ = parser.add_argument( + "-n", + "--number", + type=int, + help="number of random albums (default: 5)", + ) + _ = parser.add_argument( + "-d", + "--depth", + type=int, + help="minimum path depth to include (default: 0)", + ) + + class Arguments(argparse.Namespace): + file: Path = Path() + number: int = 5 + depth: int = 2 + + args = parser.parse_args(namespace=Arguments()) + + with open(args.file) as f: + lines = [line.strip() for line in f if line.strip()] + + if args.depth > 0: + lines = [line for line in lines if line.count("/") >= args.depth] + + if not lines: + print("file is empty or no lines match depth criteria", file=sys.stderr) + sys.exit(1) + + n: int = min(args.number, len(lines)) + selected: list[str] = random.sample(lines, n) + + for line in selected: + print(f" → {line}") + try: + result = subprocess.run(["mpc", "add", line], check=True) + _ = result + except subprocess.CalledProcessError as e: + print(f"error adding '{line}': {e}", file=sys.stderr) + except OSError: + print("mpc not found", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/speediness/.gitignore b/speediness/.gitignore @@ -0,0 +1,2 @@ +zig-out +.zig-cache diff --git a/speediness/BUILD b/speediness/BUILD @@ -0,0 +1,19 @@ +load("@rules_zig//zig:defs.bzl", "zig_binary") +load("//bazel:local-deploy.bzl", "local_deploy") + +zig_binary( + name = "speediness", + srcs = glob(["src/*.zig"]), + main = "src/main.zig", + visibility = ["//visibility:public"], + zigopts = [ + "-O", + "ReleaseSafe", + "-Doptimize=ReleaseSafe", + ], +) + +local_deploy( + name = "deploy", + srcs = [":speediness"], +) diff --git a/speediness/build.zig b/speediness/build.zig @@ -0,0 +1,28 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "speediness", + .root_module = mod, + }); + + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } +} diff --git a/speediness/build.zig.zon b/speediness/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .speediness, + .version = "0.0.0", + .fingerprint = 0x106e839d55011469, + .minimum_zig_version = "0.15.2", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/speediness/src/main.zig b/speediness/src/main.zig @@ -0,0 +1,142 @@ +// speediness: download and upload speed benchmark + +const std = @import("std"); + +fn mbps(bytes: usize, ns: u64) f64 { + const secs = @as(f64, @floatFromInt(ns)) / 1e9; + if (secs < 0.001) return 0; + return (@as(f64, @floatFromInt(bytes)) / secs) / (1024.0 * 1024.0); +} + +// ── Download ────────────────────────────────────────────────────────────────── + +fn downloadTest(allocator: std.mem.Allocator) !void { + const url = "https://proof.ovh.net/files/100Mb.dat"; + + var client: std.http.Client = .{ + .allocator = allocator, + }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + + var req = try client.request(.GET, uri, .{}); + defer req.deinit(); + + try req.sendBodiless(); + + var redirect_buffer: [8192]u8 = undefined; + var response = try req.receiveHead(&redirect_buffer); + + if (response.head.status != .ok) { + std.debug.print("{}\n", .{response.head.status}); + return error.HttpError; + } + + var buf: [131072]u8 = undefined; + var total: usize = 0; + var iter: usize = 0; + const start = try std.time.Instant.now(); + var reader = response.reader(&.{}); + + while (true) { + const n = reader.readSliceShort(&buf) catch break; + if (n == 0) break; + total += n; + iter += 1; + if (iter % 8 == 0) { + const now = try std.time.Instant.now(); + const speed = mbps(total, now.since(start)); + std.debug.print("\r{d:6.1} MB {d:6.2} MB/s {d:6.1} Mbps ", .{ + @as(f64, @floatFromInt(total)) / 1e6, + speed, + speed * 8, + }); + } + } + + const end = try std.time.Instant.now(); + const speed = mbps(total, end.since(start)); + const secs = @as(f64, @floatFromInt(end.since(start))) / 1e9; + std.debug.print("\n{d:.1} MB in {d:.2}s => {d:.2} MB/s ({d:.1} Mbps)\n", .{ + @as(f64, @floatFromInt(total)) / 1e6, secs, speed, speed * 8, + }); +} + +// ── Upload ──────────────────────────────────────────────────────────────────── + +fn uploadTest(allocator: std.mem.Allocator) !void { + const url = "https://speed.cloudflare.com/__upload"; + const upload_bytes: usize = 20 * 1024 * 1024; // 20 MB + + var client: std.http.Client = .{ + .allocator = allocator, + }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + + var req = try client.request(.POST, uri, .{ + .headers = .{ + .content_type = .{ .override = "application/octet-stream" }, + }, + }); + defer req.deinit(); + req.transfer_encoding = .{ .content_length = upload_bytes }; + + var body = try req.sendBodyUnflushed(&.{}); + + // Stream payload in 128 KB chunks + const chunk_size: usize = 131072; + const chunk = try allocator.alloc(u8, chunk_size); + defer allocator.free(chunk); + @memset(chunk, 0x55); + + var sent: usize = 0; + const start = try std.time.Instant.now(); + + while (sent < upload_bytes) { + const to_send = @min(chunk_size, upload_bytes - sent); + try body.writer.writeAll(chunk[0..to_send]); + sent += to_send; + + const now = try std.time.Instant.now(); + const speed = mbps(sent, now.since(start)); + std.debug.print("\r {d:6.1} MB {d:6.2} MB/s {d:6.1} Mbps ", .{ + @as(f64, @floatFromInt(sent)) / 1e6, + speed, + speed * 8, + }); + } + + try body.end(); + try req.connection.?.flush(); + + // Finish timing after data is flushed + const end = try std.time.Instant.now(); + + var redirect_buffer: [8192]u8 = undefined; + _ = try req.receiveHead(&redirect_buffer); + + const speed = mbps(sent, end.since(start)); + const secs = @as(f64, @floatFromInt(end.since(start))) / 1e9; + std.debug.print("\n{d:.1} MB in {d:.2}s => {d:.2} MB/s ({d:.1} Mbps)\n", .{ + @as(f64, @floatFromInt(sent)) / 1e6, secs, speed, speed * 8, + }); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + downloadTest(allocator) catch |err| { + std.debug.print("Download error: {}\n", .{err}); + }; + + uploadTest(allocator) catch |err| { + std.debug.print("Upload error: {}\n", .{err}); + }; +} diff --git a/virittaa/BUILD b/virittaa/BUILD @@ -0,0 +1,7 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("//bazel:rust.bzl", "rust_app") + +rust_app( + name = "virittaa", + deps = all_crate_deps(), +) diff --git a/virittaa/Cargo.toml b/virittaa/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "virittaa" +version = "0.1.0" +edition = "2024" + +[dependencies] +bliss-audio = { version = "0.11.2", features = ["bench"] } +bliss-audio-aubio-rs = "0.2.4" +ndarray = "0.16.1" +ndarray-stats = "0.6.0" +noisy_float = "0.2.0" +anyhow = "1.0.45" +clap = { version = "4.5.35", features = ["derive"] } +walkdir = "2.5" +lofty = "0.22.4" +rayon = "1.10" +jemallocator = "0.5" +indicatif = "0.18.3" +chrono = "0.4.43" +csv = "1.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/virittaa/Makefile.toml b/virittaa/Makefile.toml @@ -0,0 +1,35 @@ +[tasks.audit] +command = "cargo" +args = ["audit", "--color", "always"] + +[tasks.clippy] +command = "cargo" +args = ["clippy", "--", "-W", "clippy::pedantic"] + +[tasks.format] +command = "cargo" +args = ["fmt", "--", "--emit=files"] + +[tasks.test] +command = "cargo" +args = ["test"] + +[tasks.build] +command = "cargo" +args = ["build"] + +[tasks.build-release] +command = "cargo" +args = ["build", "--release"] + +[tasks.install] +command = "cargo" +args = ["install", "--path", "."] + +[tasks.checks] +dependencies = [ + "clippy", + "format", + "audit", + "test" +] diff --git a/virittaa/README.md b/virittaa/README.md @@ -0,0 +1,83 @@ +# virittaa + +`virittaa` is a command-line tool for analyzing audio files to detect `BPM` and `Key`. It is designed to be fast and accurate, with a focus on providing useful information for DJs, musicians, and music enthusiasts. + +## Features + +* **BPM Detection**: Accurately detects the tempo of a track in beats per minute. +* **Key Detection**: Determines the musical key of a track. +* **Metadata Tagging**: Optionally writes the detected BPM and Key to the audio file's metadata. +* **CSV Reporting**: Generates a CSV report of the analyzed tracks, including their BPM, Key, and other metadata. +* **Waveform Generation**: Can generate waveform images of the audio files (requires `aaltomuoto`). +* **Batch Processing**: Process multiple files at once, with support for running in parallel and cooldowns between batches. + +## Installation + +1. **Clone the repository:** + ```bash + git clone https://github.com/your-username/virittaa.git + cd virittaa + ``` +2. **Build the project:** + ```bash + cargo build --release + ``` +3. **The executable will be located at `target/release/virittaa`.** You can copy it to a directory in your `PATH` to make it accessible from anywhere on your system. + +## Dependencies + +* **`aaltomuoto`**: Required for generating waveform images. + +## Usage + +### Basic Usage + +To analyze a single file or a directory of files, simply provide the path to the file or directory as an argument: + +```bash +# Analyze a single file +virittaa path/to/track.mp3 + +# Analyze all files in a directory +virittaa path/to/music/ +``` + +### Writing Metadata + +To write the detected BPM and Key to the audio file's metadata, use the `--write-tags` or `-w` flag: + +```bash +virittaa --write-tags path/to/music/ +``` + +### Generating a CSV Report + +To generate a CSV report of the analyzed tracks, use the `--report` or `-r` flag. The report will be saved in the current directory with a timestamped filename (e.g., `virittaa-report-2023-10-27T12-00-00.csv`). + +```bash +virittaa --report path/to/music/ +``` + +### Generating Waveforms + +To generate waveform images of the audio files, use the `--waveform` or `-f` flag. This requires `aaltomuoto` to be installed and in your `PATH`. + +```bash +virittaa --waveform path/to/music/ +``` + +### Advanced Usage + +You can control the number of threads used for processing with the `--jobs` or `-j` flag: + +```bash +# Use 4 threads for processing +virittaa -j 4 path/to/music/ +``` + +You can also process files in batches with a cooldown period between each batch. This is useful for preventing your system from being overloaded when processing a large number of files. + +```bash +# Process files in batches of 10, with a 5-second cooldown between each batch +virittaa --batch-size 10 --cooldown 5 path/to/music/ +``` diff --git a/virittaa/src/audio_processing.rs b/virittaa/src/audio_processing.rs @@ -0,0 +1,166 @@ +use anyhow::Result; +use bliss_audio::chroma::bench::{chroma_stft, estimate_tuning}; +use bliss_audio::utils::bench::stft; +use bliss_audio_aubio_rs::{OnsetMode, Tempo}; +use ndarray::{Array1, Axis}; +use ndarray_stats::Quantile1dExt; +use noisy_float::types::n64; +use crate::SAMPLE_RATE; +use crate::constants::{ + BPM_HOP_SIZE, BPM_MAX, BPM_MIN, BPM_OFFSET, BPM_WINDOW_SIZE, CHROMA_BINS, KEY_HOP_SIZE, + KEY_WINDOW_SIZE, TUNING_PRECISION, +}; + +/// Calculate the BPM of a track. +/// +/// # Panics +/// +/// This function will panic if the tempo object cannot be created. +#[must_use] +pub fn calculate_bpm(samples: &[f32]) -> f32 { + let window_size = BPM_WINDOW_SIZE; + let hop_size = BPM_HOP_SIZE; + let mut tempo = Tempo::new(OnsetMode::SpecFlux, window_size, hop_size, SAMPLE_RATE) + .expect("Failed to create tempo object"); + let mut bpms = Vec::new(); + + for chunk in samples.chunks(hop_size) { + let mut padded = chunk.to_vec(); + if padded.len() < hop_size { + padded.resize(hop_size, 0.0); + } + if tempo.do_result(&padded).expect("Failed to calculate tempo") > 0.0 { + bpms.push(tempo.get_bpm()); + } + } + + if bpms.is_empty() { + return 0.0; + } + + let bpms_array = Array1::from(bpms); + #[allow(clippy::cast_possible_truncation)] + let median_bpm = *bpms_array + .mapv(|x| n64(f64::from(x))) + .quantile_mut(n64(0.5), &ndarray_stats::interpolate::Midpoint) + .expect("Failed to calculate median BPM") + .as_ref() as f32; + + normalize_bpm(median_bpm) +} + +fn normalize_bpm(mut bpm: f32) -> f32 { + if bpm > 0.0 { + bpm += BPM_OFFSET; + } + + // Constraints BPM to 80-160 + if bpm > 0.0 { + while bpm > BPM_MAX { + bpm /= 2.0; + } + while bpm < BPM_MIN { + bpm *= 2.0; + } + } + + bpm +} + +/// Calculate the key of a track. +/// +/// # Errors +/// +/// This function will return an error if the key cannot be calculated. +pub fn calculate_key(samples: &[f32]) -> Result<String> { + let global_chroma = calculate_global_chroma(samples)?; + let (best_key_idx, best_mode) = find_best_key(&global_chroma); + let key_names = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + Ok(format!("{} {}", key_names[best_key_idx], best_mode)) +} + +fn calculate_global_chroma(samples: &[f32]) -> Result<Array1<f64>> { + let window_size = KEY_WINDOW_SIZE; + let hop_size = KEY_HOP_SIZE; + + let mut spectrum = stft(samples, window_size, hop_size); + + let tuning = estimate_tuning( + SAMPLE_RATE, + &spectrum, + window_size, + TUNING_PRECISION, + CHROMA_BINS.try_into()?, + )?; + + let n_chroma = u32::try_from(CHROMA_BINS)?; + let chroma = chroma_stft(SAMPLE_RATE, &mut spectrum, window_size, n_chroma, tuning)?; + + Ok(chroma.sum_axis(Axis(1))) +} + +fn find_best_key(global_chroma: &Array1<f64>) -> (usize, &'static str) { + let major_profile = get_major_profile(); + let minor_profile = get_minor_profile(); + + let mut max_corr = -1.0; + let mut best_key_idx = 0; + let mut best_mode = "Major"; + + for i in 0..CHROMA_BINS { + let rotated_major = rotate_array(&major_profile, i); + let rotated_minor = rotate_array(&minor_profile, i); + + let corr_major = pearson_correlation(global_chroma, &rotated_major); + if corr_major > max_corr { + max_corr = corr_major; + best_key_idx = i; + best_mode = "Major"; + } + + let corr_minor = pearson_correlation(global_chroma, &rotated_minor); + if corr_minor > max_corr { + max_corr = corr_minor; + best_key_idx = i; + best_mode = "Minor"; + } + } + + (best_key_idx, best_mode) +} + +fn rotate_array(arr: &Array1<f64>, shift: usize) -> Array1<f64> { + let n = arr.len(); + Array1::from_iter((0..n).map(|j| arr[(j + n - shift) % n])) +} + +fn get_major_profile() -> Array1<f64> { + Array1::from(vec![ + 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, + ]) +} + +fn get_minor_profile() -> Array1<f64> { + Array1::from(vec![ + 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, + ]) +} + +fn pearson_correlation(v1: &Array1<f64>, v2: &Array1<f64>) -> f64 { + let mean1 = v1.mean().expect("Failed to calculate mean"); + let mean2 = v2.mean().expect("Failed to calculate mean"); + let num: f64 = v1 + .iter() + .zip(v2.iter()) + .map(|(x, y)| (x - mean1) * (y - mean2)) + .sum(); + let den1: f64 = v1.iter().map(|x| (x - mean1).powi(2)).sum(); + let den2: f64 = v2.iter().map(|y| (y - mean2).powi(2)).sum(); + if den1 == 0.0 || den2 == 0.0 { + 0.0 + } else { + num / (den1.sqrt() * den2.sqrt()) + } +} diff --git a/virittaa/src/constants.rs b/virittaa/src/constants.rs @@ -0,0 +1,24 @@ +//! Audio processing constants + +// BPM +pub const BPM_WINDOW_SIZE: usize = 1024; +pub const BPM_HOP_SIZE: usize = 128; +pub const BPM_OFFSET: f32 = -1.15; +pub const BPM_MIN: f32 = 80.0; +pub const BPM_MAX: f32 = 160.0; + +// Key +pub const KEY_WINDOW_SIZE: usize = 8192; +pub const KEY_HOP_SIZE: usize = 2205; +pub const TUNING_PRECISION: f64 = 0.01; +pub const CHROMA_BINS: usize = 12; + +// Waveform +pub const WAVEFORM_WIDTH: usize = 2000; +pub const WAVEFORM_HEIGHT: usize = 350; + +// Supported audio file extensions +pub const SUPPORTED_EXTENSIONS: &[&str] = &[ + "aac", "ape", "aif", "aiff", "flac", "mp3", "mp4", "m4a", "mpc", "opus", "ogg", "spx", "wav", + "wv", +]; diff --git a/virittaa/src/file_io.rs b/virittaa/src/file_io.rs @@ -0,0 +1,231 @@ +use lofty::prelude::TaggedFileExt; +use crate::constants::{WAVEFORM_HEIGHT, WAVEFORM_WIDTH}; +use anyhow::Result; +use chrono::Local; +use lofty::config::WriteOptions; +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::{ItemKey, Tag}; +use std::borrow::Cow; +use std::path::Path; +use std::process::Command as ProcessCommand; + +use crate::audio_processing::{calculate_bpm, calculate_key}; +use crate::types::{TrackError, TrackInfo}; +use bliss_audio::decoder::ffmpeg::FFmpegDecoder as Decoder; +use bliss_audio::decoder::Decoder as DecoderTrait; + +/// Metadata extracted from a file's tags. +#[derive(Debug, Default)] +pub struct FileMetadata { + pub artist: Option<String>, + pub album: Option<String>, + pub track: Option<String>, + pub label: Option<String>, +} + +/// Process a single file. +/// +/// # Errors +/// +/// This function will return an error if the file cannot be processed. +pub fn process_file(path: &Path, write_tags: bool) -> Result<TrackInfo, String> { + let song = Decoder::decode(path).map_err(|e| format!("Error decoding: {e}"))?; + let samples = &song.sample_array; + + let bpm = calculate_bpm(samples); + let key = calculate_key(samples).map_err(|e| format!("Error calculating key: {e}"))?; + + let metadata = read_metadata(path); + + println!("File: {}, BPM: {bpm:.1}, Key: {key}", path.display()); + + if write_tags && let Err(e) = write_metadata(path, bpm, &key) { + eprintln!("Error writing tags for {}: {e}", path.display()); + } + + Ok(TrackInfo { + path: path.to_path_buf(), + artist: metadata.artist, + album: metadata.album, + track: metadata.track, + label: metadata.label, + bpm, + key, + }) +} + +/// Read metadata from an audio file. +/// +/// Returns default values if the file cannot be read or has no tags. +pub fn read_metadata(path: &Path) -> FileMetadata { + match Probe::open(path).and_then(Probe::read) { + Ok(tagged_file) => { + let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()); + if let Some(t) = tag { + FileMetadata { + artist: t + .get_string(&ItemKey::TrackArtist) + .or_else(|| t.get_string(&ItemKey::AlbumArtist)) + .map(String::from), + album: t.get_string(&ItemKey::AlbumTitle).map(String::from), + track: t.get_string(&ItemKey::TrackTitle).map(String::from), + label: t.get_string(&ItemKey::Label).map(String::from), + } + } else { + FileMetadata::default() + } + } + Err(_) => FileMetadata::default(), + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "PascalCase")] +struct ReportRecord<'a> { + path: Cow<'a, str>, + artist: Option<&'a str>, + album: Option<&'a str>, + track: Option<&'a str>, + label: Option<&'a str>, + #[serde(rename = "BPM")] + bpm: Option<String>, + key: Option<&'a str>, + status: &'a str, + error: Option<&'a str>, +} + +/// Write a report of the processed tracks. +/// +/// # Errors +/// +/// This function will return an error if the report cannot be written. +pub fn write_report(results: &[TrackInfo], errors: &[TrackError]) -> Result<()> { + let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S").to_string(); + let filename = format!("virittaa-report-{timestamp}.csv"); + + let mut wtr = csv::Writer::from_path(&filename)?; + + for track in results { + wtr.serialize(ReportRecord { + path: track.path.to_string_lossy(), + artist: track.artist.as_deref(), + album: track.album.as_deref(), + track: track.track.as_deref(), + label: track.label.as_deref(), + bpm: Some(format!("{:.1}", track.bpm)), + key: Some(&track.key), + status: "Success", + error: None, + })?; + } + + for error in errors { + wtr.serialize(ReportRecord { + path: error.path.to_string_lossy(), + artist: None, + album: None, + track: None, + label: None, + bpm: None, + key: None, + status: "Error", + error: Some(&error.reason), + })?; + } + + wtr.flush()?; + println!("Report written to: {filename}"); + Ok(()) +} + +pub fn generate_waveform_images(results: &[TrackInfo]) { + if let Err(e) = std::fs::create_dir_all("meta") { + eprintln!("Error creating meta directory: {e}"); + return; + } + for track in results { + generate_waveform_image(track); + } +} + +fn generate_waveform_image(track: &TrackInfo) { + let artist = track.artist.as_deref().unwrap_or("unknown"); + let album = track.album.as_deref().unwrap_or("unknown"); + let title = track.track.as_deref().unwrap_or("unknown"); + + let output_name = format!( + "meta/{}_{}_{}_{}.png", + sanitize_filename(artist), + sanitize_filename(album), + sanitize_filename(title), + "waveform" + ) + .to_lowercase(); + + let track_path = track.path.to_string_lossy(); + + match ProcessCommand::new("aaltomuoto") + .arg(&*track_path) + .arg(&output_name) + .arg(WAVEFORM_WIDTH.to_string()) + .arg(WAVEFORM_HEIGHT.to_string()) + .output() + { + Ok(output) => { + if output.status.success() { + println!("Waveform generated: {output_name}"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("Error generating waveform for {track_path}: {stderr}"); + } + } + Err(e) => { + eprintln!("Failed to run aaltomuoto for {track_path}: {e}"); + } + } +} + +/// Sanitize a string for use in a filename. +/// +/// Replaces non-alphanumeric characters (except `-`) with underscores. +fn sanitize_filename(s: &str) -> String { + s.chars() + .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }) + .collect() +} + +/// Write metadata to a file. +/// +/// # Panics +/// +/// This function will panic if the metadata cannot be written. +/// +/// # Errors +/// +/// This function will return an error if the metadata cannot be written. +pub fn write_metadata(path: &Path, bpm: f32, key: &str) -> Result<()> { + let mut tagged_file = Probe::open(path)?.read()?; + let tag = match tagged_file.primary_tag_mut() { + Some(primary_tag) => primary_tag, + None => { + if let Some(first_tag) = tagged_file.first_tag_mut() { + first_tag + } else { + // If there are no tags, create a new one based on the file type + let tag_type = tagged_file.primary_tag_type(); + tagged_file.insert_tag(Tag::new(tag_type)); + tagged_file + .primary_tag_mut() + .expect("Failed to create new tag") + } + } + }; + + tag.insert_text(ItemKey::Bpm, bpm.round().to_string()); + tag.insert_text(ItemKey::InitialKey, key.to_string()); + tag.insert_text(ItemKey::Unknown("TKEY".to_string()), key.to_string()); + + tag.save_to_path(path, WriteOptions::default())?; + Ok(()) +} diff --git a/virittaa/src/main.rs b/virittaa/src/main.rs @@ -0,0 +1,231 @@ +pub mod audio_processing; +pub mod constants; +pub mod file_io; +pub mod types; + +use anyhow::Result; +use clap::{Arg, Command}; +use rayon::prelude::*; +use std::path::PathBuf; +use walkdir::WalkDir; + +use crate::file_io::{generate_waveform_images, process_file, write_report}; +use crate::types::{TrackError, TrackInfo}; +use std::sync::Mutex; + +#[global_allocator] +static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; + +fn is_supported_audio_file(path: &std::path::Path) -> bool { + path.is_file() + && path + .extension() + .is_some_and(is_supported_extension) +} + +fn is_supported_extension(ext: &std::ffi::OsStr) -> bool { + let ext_str = ext.to_string_lossy().to_lowercase(); + crate::constants::SUPPORTED_EXTENSIONS.contains(&ext_str.as_ref()) +} + +pub const SAMPLE_RATE: u32 = 22050; + +fn main() -> Result<()> { + let matches = Command::new("virittaa") + .arg(Arg::new("path").required(true).num_args(1..)) + .arg( + Arg::new("write-tags") + .short('w') + .long("write-tags") + .action(clap::ArgAction::SetTrue) + .help("Write BPM and Key to file metadata"), + ) + .arg( + Arg::new("jobs") + .short('j') + .long("jobs") + .help("Number of threads to use") + .default_value("0") + .value_parser(clap::value_parser!(usize)), + ) + .arg( + Arg::new("batch-size") + .short('b') + .long("batch-size") + .help("Number of files to process before cooldown (0 = off)") + .default_value("0") + .value_parser(clap::value_parser!(usize)), + ) + .arg( + Arg::new("cooldown") + .short('c') + .long("cooldown") + .help("Cooldown in seconds between batches") + .default_value("0") + .value_parser(clap::value_parser!(u64)), + ) + .arg( + Arg::new("report") + .short('r') + .long("report") + .action(clap::ArgAction::SetTrue) + .help("Generate a CSV report file in the current directory"), + ) + .arg( + Arg::new("waveform") + .short('f') + .long("waveform") + .action(clap::ArgAction::SetTrue) + .help("Generate waveform images using aaltomuoto"), + ) + .get_matches(); + + let write_tags = matches.get_flag("write-tags"); + let generate_report = matches.get_flag("report"); + let generate_waveforms = matches.get_flag("waveform"); + let jobs = *matches + .get_one::<usize>("jobs") + .expect("Failed to get jobs"); + let batch_size = *matches + .get_one::<usize>("batch-size") + .expect("Failed to get batch size"); + let cooldown_secs = *matches + .get_one::<u64>("cooldown") + .expect("Failed to get cooldown"); + + if jobs > 0 { + rayon::ThreadPoolBuilder::new() + .num_threads(jobs) + .build_global() + .expect("Failed to build thread pool"); + } + + let path: Vec<String> = matches + .get_many::<String>("path") + .expect("Failed to get path") + .map(std::clone::Clone::clone) + .collect(); + + run_processing( + path, + batch_size, + cooldown_secs, + write_tags, + generate_report, + generate_waveforms, + ) +} + +#[allow(clippy::fn_params_excessive_bools)] +fn run_processing( + paths: Vec<String>, + batch_size: usize, + cooldown_secs: u64, + write_tags: bool, + generate_report: bool, + generate_waveforms: bool, +) -> Result<()> { + let mut files_to_process = Vec::new(); + + for path_str in paths { + let path = PathBuf::from(&path_str); + if path.is_dir() { + for entry in WalkDir::new(path).into_iter().filter_map(Result::ok) { + let path = entry.path(); + if is_supported_audio_file(path) { + files_to_process.push(path.to_path_buf()); + } + } + } else if path.is_file() { + files_to_process.push(path); + } + } + + let (results, errors) = process_files(&files_to_process, batch_size, cooldown_secs, write_tags); + + if generate_report { + write_report(&results, &errors)?; + } + + if generate_waveforms { + generate_waveform_images(&results); + } + + Ok(()) +} + +fn process_files( + files_to_process: &[PathBuf], + batch_size: usize, + cooldown_secs: u64, + write_tags: bool, +) -> (Vec<TrackInfo>, Vec<TrackError>) { + let results: Mutex<Vec<TrackInfo>> = Mutex::new(Vec::new()); + let errors: Mutex<Vec<TrackError>> = Mutex::new(Vec::new()); + + let process_file_with_lock = |path: &PathBuf| { + match process_file(path, write_tags) { + Ok(info) => results.lock().expect("Failed to lock results").push(info), + Err(reason) => errors + .lock() + .expect("Failed to lock errors") + .push(TrackError { + path: path.clone(), + reason, + }), + } + }; + + if batch_size == 0 { + files_to_process.par_iter().for_each(|path| { + process_file_with_lock(path); + }); + } else { + process_in_batches( + files_to_process, + batch_size, + cooldown_secs, + process_file_with_lock, + ); + } + + ( + results.into_inner().expect("Failed to get results"), + errors.into_inner().expect("Failed to get errors"), + ) +} + +fn process_in_batches( + files: &[PathBuf], + batch_size: usize, + cooldown_secs: u64, + process_fn: impl Fn(&PathBuf) + Sync, +) { + let chunks: Vec<_> = files.chunks(batch_size).collect(); + let total_batches = chunks.len(); + + let pb = indicatif::ProgressBar::new(files.len() as u64); + pb.set_style( + indicatif::ProgressStyle::default_bar() + .template( + "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})", + ) + .expect("Failed to create progress bar style") + .progress_chars("#>-"), + ); + + for (batch_idx, batch) in chunks.iter().enumerate() { + batch.par_iter().for_each(|path| { + process_fn(path); + pb.inc(1); + }); + + if batch_idx < total_batches - 1 { + pb.set_message(format!("Cooldown {cooldown_secs} seconds...")); + std::thread::sleep(std::time::Duration::from_secs(cooldown_secs)); + pb.set_message(""); + } + } + + pb.finish_with_message("Done!"); +} diff --git a/virittaa/src/types.rs b/virittaa/src/types.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +pub struct TrackInfo { + pub path: PathBuf, + pub artist: Option<String>, + pub album: Option<String>, + pub track: Option<String>, + pub label: Option<String>, + pub bpm: f32, + pub key: String, +} + +pub struct TrackError { + pub path: PathBuf, + pub reason: String, +}