This commit is contained in:
Lukas Wurzinger 2025-01-04 23:51:35 +01:00
parent 0f6a49366e
commit 024ea1168a
No known key found for this signature in database
23 changed files with 485 additions and 101 deletions

View file

@ -13,5 +13,5 @@
'';
};
config.pubkeys = import (self + /pubkeys.nix);
config.pubkeys = lib.mkForce (import (self + /pubkeys.nix));
}

View file

@ -1,13 +1,9 @@
{pkgs, ...}: let
puter = pkgs.writeShellApplication {
name = "puter";
runtimeInputs = [
pkgs.nixos-rebuild
];
text = builtins.readFile ./puter.bash;
};
in {
{
pkgs,
self,
...
}: {
environment.systemPackages = [
puter
self.packages.${pkgs.system}.puter
];
}

View file

@ -1,4 +1,6 @@
{
languages.python.enable = true;
pre-commit.hooks = {
# Nix
alejandra.enable = true;
@ -10,5 +12,10 @@
# Shell
shellcheck.enable = true;
# Python
pyright.enable = true;
ruff.enable = true;
ruff-format.enable = true;
};
}

View file

@ -48,7 +48,7 @@
devenv.root = let
devenvRootFileContent = builtins.readFile inputs.devenv-root.outPath;
in
pkgs.lib.mkIf (devenvRootFileContent != "") devenvRootFileContent;
self.lib.mkIf (devenvRootFileContent != "") devenvRootFileContent;
name = "puter";
@ -61,7 +61,14 @@
];
};
packages.disk = pkgs.callPackage ./disk {};
packages =
self.lib.genAttrs [
"puter"
"disk"
"musicomp"
] (
name: pkgs.callPackage ./packages/${name} {}
);
};
};
}

View file

@ -1,84 +0,0 @@
{
self,
lib,
pkgs,
...
}: let
audiocomp = pkgs.writeShellApplication {
name = "audiocomp";
runtimeInputs = [
pkgs.parallel
pkgs.rsync
pkgs.openssh
];
text = let
remoteDir = self.nixosConfigurations.abacus.config.services.navidrome.settings.MusicFolder;
enc = pkgs.writeShellApplication {
name = "enc";
runtimeInputs = [
pkgs.opusTools
];
text = ''
src=$1
dst=$src
dst=''${dst%.flac}.opus
dst=/srv/compmusic/''${dst#/srv/music/}
if [[ -f "$dst" ]]; then
exit
fi
mkdir --parents -- "$(dirname -- "$dst")"
echo "encoding ''${src@Q} -> ''${dst@Q}" >&2
exec opusenc --quiet --bitrate 96.000 -- "$src" "$dst"
'';
};
clean = pkgs.writeShellApplication {
name = "clean";
text = ''
del=$1
chk=$del
chk=''${chk%.opus}.flac
chk=/srv/music/''${chk#/srv/compmusic/}
if [[ ! -f "$chk" ]]; then
echo "deleting ''${del@Q}" >&2
rm --force -- "$del"
fi
'';
};
in ''
shopt -s globstar nullglob
find /srv/music -name '*.flac' -print0 | parallel --null -- ${lib.getExe enc} {}
find /srv/compmusic -name '*.flac' -exec ${clean} {} \;
echo syncing >&2
rsync --verbose --verbose --archive --update --delete --mkpath --exclude lost+found \
--rsh 'ssh -i /etc/ssh/ssh_host_ed25519_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' \
-- /srv/compmusic/ root@wrz.one:${remoteDir}
'';
};
in {
systemd.services.audiocomp = {
description = "Compress and sync music";
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
ExecStart = lib.getExe audiocomp;
};
};
systemd.timers.audiocomp = {
description = "Compress and sync music daily";
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "*-*-* 03:00:00";
Persistent = true;
Unit = "audiocomp.service";
};
};
}

View file

@ -0,0 +1,51 @@
{
self,
lib,
pkgs,
...
}: {
services.musicomp.jobs.main = {
music = "/srv/music";
comp = "/srv/compmusic";
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
inhibitsSleep = true;
post = let
remoteDir = self.nixosConfigurations.abacus.config.services.navidrome.settings.MusicFolder;
rsyncExe = lib.getExe pkgs.rsync;
in ''
${rsyncExe} \
--archive \
--recursive \
--delete \
--update \
--mkpath \
--verbose --verbose \
--exclude lost+found \
--rsh 'ssh -i /etc/ssh/ssh_host_ed25519_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' \
/srv/compmusic/ root@wrz.one:${remoteDir}
'';
};
systemd.services.audiocomp = {
description = "Compress and sync music";
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
ExecStart = lib.getExe audiocomp;
};
};
systemd.timers.audiocomp = {
description = "Compress and sync music daily";
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "*-*-* 03:00:00";
Persistent = true;
Unit = "audiocomp.service";
};
};
}

10
lib.nix
View file

@ -1,13 +1,13 @@
lib: _: {
findModules = dirs:
builtins.concatMap (dir:
lib.pipe dir [
findModules = paths:
builtins.concatMap (path:
lib.pipe path [
(lib.fileset.fileFilter (
file: file.hasExt "nix"
))
lib.fileset.toList
])
dirs;
paths;
formatHostPort = {
host,
@ -30,6 +30,7 @@ lib: _: {
inputs,
extraModules ? _: [],
}: let
modulesDir = ./modules;
commonDir = ./common;
classesDir = ./classes;
hostsDir = ./hosts;
@ -47,6 +48,7 @@ lib: _: {
modules =
(lib.findModules [
modulesDir
commonDir
./classes/${class}
(classesDir + /${class})

120
modules/musicomp.nix Normal file
View file

@ -0,0 +1,120 @@
{
self,
lib,
pkgs,
utils,
...
}: let
inherit (lib) types;
inherit (utils.systemdUtils.unitOptions) unitOption;
in {
options.services.musicomp.jobs = lib.mkOption {
description = ''
Periodic jobs to run with musicomp.
'';
# type = types.attrsOf (types.submodule ({name, ...}: {
type = types.attrsOf (types.submodule {
options = {
music = lib.mkOption {
type = types.str;
description = ''
Source directory.
'';
example = "/srv/music";
};
comp = lib.mkOption {
type = types.str;
description = ''
Destination directory for compressed music.
'';
example = "/srv/comp";
};
post = lib.mkOption {
type = types.lines;
default = "";
description = ''
Shell commands that are run after compression has finished.
'';
};
workers = lib.mkOption {
type = lib.types.int;
default = 0;
description = ''
Number of workers.
'';
};
timerConfig = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf unitOption);
default = {
OnCalendar = "daily";
Persistent = true;
};
description = ''
When to run the job.
'';
};
package = lib.mkPackageOption self.packages.${pkgs.system} "musicomp" {};
inhibitsSleep = lib.mkOption {
default = false;
type = lib.types.bool;
example = true;
description = ''
Prevents the system from sleeping while running the job.
'';
};
};
description = ''
Periodic compression jobs to run with musicomp.
'';
default = {};
});
};
config = {
systemd.services =
lib.mapAttrs'
(
name: job:
lib.nameValuePair "musicomp-jobs-${name}" {
restartIfChanged = false;
# TODO
wants = ["network-online.target"];
after = ["network-online.target"];
script = ''
${lib.optionalString job.inhibitsSleep ''
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
--mode block \
--who musicomp \
--what sleep \
--why ${lib.escapeShellArg "Scheduled musicomp ${name}"}
''}
${lib.getExe job.package} \
${lib.optionalString (job.workers > 0) "--workers ${job.workers}"} \
-- ${job.music} ${job.comp}
'';
postStop = job.post;
serviceConfig.Type = "oneshot";
}
)
config.services.musicomp.jobs;
systemd.timers =
lib.mapAttrs'
(name: job:
lib.nameValuePair "musicomp-jobs-${name}" {
wantedBy = ["timers.target"];
inherit (job) timerConfig;
})
(lib.filterAttrs (_: job: job.timerConfig != null) config.services.musicomp.jobs);
};
}

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,19 @@
{
lib,
python3Packages,
opusTools,
}:
python3Packages.buildPythonApplication {
pname = "musicomp";
version = "0.1.0";
src = ./.;
pyproject = true;
doCheck = false;
build-system = [python3Packages.hatchling];
makeWrapperArgs = [
"--prefix"
"PATH"
":"
(lib.makeBinPath [opusTools])
];
}

View file

@ -0,0 +1,19 @@
[project]
name = "musicomp"
version = "0.1.0"
description = ""
authors = [
{name = "Lukas Wurzinger", email = "lukas@wrz.one"}
]
readme = "README.md"
requires-python = ">=3.12"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.scripts]
musicomp = "musicomp.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/musicomp"]

View file

@ -0,0 +1,3 @@
from .cli import main
main()

View file

@ -0,0 +1,6 @@
from os import PathLike
from pathlib import Path
def clean(dst: str | PathLike[str]) -> None:
Path(dst).unlink(missing_ok=True)

View file

@ -0,0 +1,118 @@
import sys
from multiprocessing import Pool, cpu_count
from argparse import ArgumentParser, ArgumentTypeError, Namespace
from pathlib import Path
from .todo import Todo
args = Namespace()
def task(todo: Todo) -> None:
todo.run()
if args.verbose:
print("finished", todo, file=sys.stderr)
def main():
def workers_type_func(value: object) -> int:
try:
value = int(value) # pyright: ignore[reportArgumentType]
if value <= 0:
raise ArgumentTypeError(f"{value} is not a positive integer")
except ValueError:
raise ArgumentTypeError(f"{value} is not an integer")
return value
parser = ArgumentParser()
_ = parser.add_argument(
"-w",
"--workers",
default=cpu_count(),
type=workers_type_func,
help="amount of worker processes",
)
_ = parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="prompt before running",
)
_ = parser.add_argument(
"-k",
"--keep",
action="store_true",
help="whether source files should be kept if both directories are the same",
)
_ = parser.add_argument(
"-r",
"--redo",
action="store_true",
help="whether everything should be re-encoded regardless of whether they have already been transcoded",
)
_ = parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="verbose output",
)
_ = parser.add_argument(
"music",
nargs=1,
type=Path,
help="the source directory",
)
_ = parser.add_argument(
"comp",
nargs=1,
type=Path,
help="the destination directory for compressed files",
)
global args
args = parser.parse_args(sys.argv[1:])
assert isinstance(args.workers, int) # pyright: ignore[reportAny]
assert isinstance(args.interactive, bool) # pyright: ignore[reportAny]
assert isinstance(args.keep, bool) # pyright: ignore[reportAny]
assert isinstance(args.redo, bool) # pyright: ignore[reportAny]
assert isinstance(args.verbose, int) # pyright: ignore[reportAny]
assert isinstance(args.music, list) # pyright: ignore[reportAny]
assert len(args.music) == 1 # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
assert isinstance(args.music[0], Path) # pyright: ignore[reportUnknownMemberType]
assert isinstance(args.comp, list) # pyright: ignore[reportAny]
assert len(args.comp) == 1 # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
assert isinstance(args.comp[0], Path) # pyright: ignore[reportUnknownMemberType]
src_dir = args.music[0] # pyright: ignore[reportUnknownMemberType]
dst_dir = args.comp[0] # pyright: ignore[reportUnknownMemberType]
plan = list(
Todo.plan(
src_dir,
dst_dir,
replace=src_dir.samefile(dst_dir) and not args.keep,
redo=args.redo,
)
)
if len(plan) == 0:
print("Nothing to do", file=sys.stderr)
sys.exit(0)
if args.verbose >= 1 or args.interactive:
print("Plan:", file=sys.stderr)
for todo in plan:
print(todo, file=sys.stderr)
if args.interactive:
result = input("Do you want to continue? [Y/n] ")
if result.lower() not in ("", "y", "yes"):
sys.exit(1)
with Pool(args.workers) as pool:
_ = pool.map(task, plan)

View file

@ -0,0 +1,6 @@
from os import PathLike
from pathlib import Path
def replace(src: str | PathLike[str]) -> None:
Path(src).unlink(missing_ok=True)

View file

@ -0,0 +1,68 @@
from pathlib import Path
from os import PathLike
from enum import StrEnum
from dataclasses import dataclass
from typing import Self, override
from collections.abc import Generator
from .transcode import transcode as todo_transcode
from .clean import clean as todo_clean
from .replace import replace as todo_replace
SRC_SUFFIX = ".flac"
DST_SUFFIX = ".opus"
class TodoAct(StrEnum):
TRANSCODE = "transcode"
CLEAN = "clean"
REPLACE = "replace"
@dataclass
class Todo:
act: TodoAct
src: str | PathLike[str]
dst: str | PathLike[str]
@classmethod
def plan(
cls: type[Self],
src_dir: str | PathLike[str],
dst_dir: str | PathLike[str],
replace: bool = False,
redo: bool = False,
) -> Generator[Self]:
def list_files(dir: str | PathLike[str], suffix: str) -> list[Path]:
files: list[Path] = []
for f in Path(dir).rglob("*"):
if f.is_file() and f.suffix == suffix:
files.append(f)
return files
src_files = list_files(src_dir, SRC_SUFFIX)
dst_files = list_files(dst_dir, DST_SUFFIX)
for f in src_files:
e = dst_dir / (f.relative_to(src_dir).with_suffix(DST_SUFFIX))
if redo or e not in dst_files:
yield cls(TodoAct.TRANSCODE, f, e)
if replace:
yield cls(TodoAct.REPLACE, f, e)
for f in dst_files:
e = src_dir / (f.relative_to(dst_dir).with_suffix(SRC_SUFFIX))
if e not in src_files:
yield cls(TodoAct.CLEAN, f, e)
def run(self) -> None:
match self.act:
case TodoAct.TRANSCODE:
todo_transcode(self.src, self.dst)
case TodoAct.CLEAN:
todo_clean(self.dst)
case TodoAct.REPLACE:
todo_replace(self.src)
@override
def __str__(self) -> str:
return f"{self.act} {self.src} -> {self.dst}"

View file

@ -0,0 +1,34 @@
from pathlib import Path
from subprocess import run
from os import PathLike
class TranscodingError(Exception):
pass
def transcode(src: str | PathLike[str], dst: str | PathLike[str]) -> None:
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.is_file():
dst.unlink()
opusenc: tuple[str, ...] = (
"opusenc",
"--quiet",
"--bitrate",
"96.000",
"--music",
"--vbr",
"--comp",
"10",
"--",
str(src),
str(dst),
)
cp = run(opusenc)
if cp.returncode != 0:
raise TranscodingError(f"opusenc exited with code {cp.returncode}")

View file

@ -0,0 +1,11 @@
{
writeShellApplication,
nixos-rebuild,
}:
writeShellApplication {
name = "puter";
runtimeInputs = [
nixos-rebuild
];
text = builtins.readFile ./puter.bash;
}