commit 6c173ae3dedce8f80eddfa255bd749b82304dd48 Author: Lukas Wurzinger Date: Sun Apr 6 21:03:09 2025 +0200 init diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..cb982f0 --- /dev/null +++ b/.envrc @@ -0,0 +1,9 @@ +watch_file flake.nix +watch_file flake.lock + +DEVENV_ROOT_FILE="$(mktemp)" +printf %s "$PWD" > "$DEVENV_ROOT_FILE" +if ! use flake . --override-input devenv-root "file+file://$DEVENV_ROOT_FILE" +then + echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 +fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55281ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.direnv/ +.devenv/ + +.pre-commit-config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4483c72 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# musicomp + +A simple music compression tool. + +If you're anything like me, you obtain music in FLAC format for archival and compress it down to a codec such as Opus for actual listening. +musicomp exists to automate the continuous compression work that would otherwise have to be done manually. + +It: + +* scans the source and destination directories, +* makes sure the content is properly synchronized (i.e., there are no tracks in destination that don't exist in source and vice versa), and +* compresses the music from FLAC to Opus in parallel. + +## Dependencies + +musicomp requires `opusenc` in `$PATH` in order to function. +The Nix package wraps musicomp to include `opusenc` out of the box. + +## NixOS + +A NixOS module is provided within this flake. +Here's an example of how to use it: + +```nix +{ + self, + lib, + pkgs, + ... +}: { + services.musicomp.jobs.main = { + music = "/srv/music"; + comp = "/srv/compmusic"; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + }; + inhibitsSleep = true; + post = let + remoteDir = "/my/music/folder"; + rsyncExe = lib.getExe pkgs.rsync; + rsh = "${lib.getExe pkgs.openssh} -i /etc/ssh/ssh_host_ed25519_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; + in '' + ${rsyncExe} \ + --archive \ + --recursive \ + --delete \ + --update \ + --mkpath \ + --verbose --verbose \ + --exclude lost+found \ + --rsh ${lib.escapeShellArg rsh} \ + /srv/compmusic/ root@my.server:${remoteDir} + ''; + }; +} +``` diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,3 @@ +{ + +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c3383d1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,307 @@ +{ + "nodes": { + "cachix": { + "inputs": { + "devenv": [ + "devenv" + ], + "flake-compat": [ + "devenv" + ], + "git-hooks": [ + "devenv" + ], + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1742042642, + "narHash": "sha256-D0gP8srrX0qj+wNYNPdtVJsQuFzIng3q43thnHXQ/es=", + "owner": "cachix", + "repo": "cachix", + "rev": "a624d3eaf4b1d225f918de8543ed739f2f574203", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "flake-compat": "flake-compat", + "git-hooks": "git-hooks", + "nix": "nix", + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1743783972, + "narHash": "sha256-5wPsNCnWmeLpLxavsftA9L7tnYgtlexV7FwLegxtpy4=", + "owner": "cachix", + "repo": "devenv", + "rev": "2f53e2f867e0c2ba18b880e66169366e5f8ca554", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "devenv-root": { + "flake": false, + "locked": { + "narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=", + "type": "file", + "url": "file:///dev/null" + }, + "original": { + "type": "file", + "url": "file:///dev/null" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1712014858, + "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": [ + "devenv" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1742649964, + "narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "devenv", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "libgit2": { + "flake": false, + "locked": { + "lastModified": 1697646580, + "narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=", + "owner": "libgit2", + "repo": "libgit2", + "rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5", + "type": "github" + }, + "original": { + "owner": "libgit2", + "repo": "libgit2", + "type": "github" + } + }, + "nix": { + "inputs": { + "flake-compat": [ + "devenv" + ], + "flake-parts": "flake-parts", + "libgit2": "libgit2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-23-11": [ + "devenv" + ], + "nixpkgs-regression": [ + "devenv" + ], + "pre-commit-hooks": [ + "devenv" + ] + }, + "locked": { + "lastModified": 1741798497, + "narHash": "sha256-E3j+3MoY8Y96mG1dUIiLFm2tZmNbRvSiyN7CrSKuAVg=", + "owner": "domenkozar", + "repo": "nix", + "rev": "f3f44b2baaf6c4c6e179de8cbb1cc6db031083cd", + "type": "github" + }, + "original": { + "owner": "domenkozar", + "ref": "devenv-2.24", + "repo": "nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1733212471, + "narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "55d15ad12a74eb7d4646254e13638ad0c4128776", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1743296961, + "narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1717432640, + "narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "88269ab3044128b7c2f4c7d68448b2fb50456870", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1733477122, + "narHash": "sha256-qamMCz5mNpQmgBwc8SB5tVMlD5sbwVIToVZtSxMph9s=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1743827369, + "narHash": "sha256-rpqepOZ8Eo1zg+KJeWoq1HAOgoMCDloqv5r2EAa9TSA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "42a1c966be226125b48c384171c44c651c236c22", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "devenv-root": "devenv-root", + "flake-parts": "flake-parts_2", + "nixpkgs": "nixpkgs_4" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..62bf405 --- /dev/null +++ b/flake.nix @@ -0,0 +1,63 @@ +{ + description = "My NixOS configuration"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + devenv-root = { + url = "file+file:///dev/null"; + flake = false; + }; + devenv.url = "github:cachix/devenv"; + }; + + outputs = { + self, + nixpkgs, + flake-parts, + ... + } @ inputs: + flake-parts.lib.mkFlake {inherit inputs;} { + imports = [ + inputs.devenv.flakeModule + ]; + + systems = ["x86_64-linux" "aarch64-linux"]; + + flake.nixosModules = let + musicomp = import ./nixos/musicomp.nix inputs; + in { + inherit musicomp; + default = musicomp; + }; + + perSystem = { + pkgs, + inputs', + lib, + ... + }: { + devenv.shells.default = { + devenv.root = let + devenvRootFileContent = builtins.readFile inputs.devenv-root.outPath; + in + lib.mkIf (devenvRootFileContent != "") devenvRootFileContent; + + name = "musicomp"; + + imports = [ + ./devenv.nix + ]; + }; + + packages = let + packages = lib.packagesFromDirectoryRecursive { + inherit (pkgs) callPackage; + directory = ./packages; + }; + in packages // { + default = packages.musicomp; + }; + }; + }; +} diff --git a/nixos/musicomp.nix b/nixos/musicomp.nix new file mode 100644 index 0000000..e0e3062 --- /dev/null +++ b/nixos/musicomp.nix @@ -0,0 +1,115 @@ +self: { + lib, + pkgs, + utils, + config, + ... +}: let + inherit (lib) types; + inherit (utils.systemdUtils.unitOptions) unitOption; +in { + options.services.musicomp.jobs = lib.mkOption { + description = '' + Compression jobs to run with musicomp. + ''; + default = {}; + 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. A number less than 1 means that musicomp will use the amount of available processor threads. + ''; + }; + + 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. + ''; + }; + }; + }); + }; + + config = { + systemd.services = + lib.mapAttrs' + ( + name: job: + lib.nameValuePair "musicomp-jobs-${name}" { + wantedBy = ["multi-user.target"]; + restartIfChanged = false; + + 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}"} \ + --verbose \ + -- ${job.music} ${job.comp} + ''; + + postStart = 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); + }; +} diff --git a/packages/musicomp/package.nix b/packages/musicomp/package.nix new file mode 100644 index 0000000..a740116 --- /dev/null +++ b/packages/musicomp/package.nix @@ -0,0 +1,26 @@ +{ + 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]) + ]; + + meta.mainProgram = "musicomp"; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..70f3218 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "musicomp" +version = "0.1.0" +description = "Music compression tool" +authors = [{ name = "Lukas Wurzinger", email = "lukas@wrz.one" }] +readme = "README.md" +requires-python = ">=3.12" +license = "Apache-2.0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.scripts] +musicomp = "musicomp.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/musicomp"] diff --git a/src/musicomp/__main__.py b/src/musicomp/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/src/musicomp/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/src/musicomp/clean.py b/src/musicomp/clean.py new file mode 100644 index 0000000..2e2107d --- /dev/null +++ b/src/musicomp/clean.py @@ -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) diff --git a/src/musicomp/cli.py b/src/musicomp/cli.py new file mode 100644 index 0000000..c9fc7aa --- /dev/null +++ b/src/musicomp/cli.py @@ -0,0 +1,117 @@ +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) diff --git a/src/musicomp/replace.py b/src/musicomp/replace.py new file mode 100644 index 0000000..097f5f3 --- /dev/null +++ b/src/musicomp/replace.py @@ -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) diff --git a/src/musicomp/todo.py b/src/musicomp/todo.py new file mode 100644 index 0000000..b9e2142 --- /dev/null +++ b/src/musicomp/todo.py @@ -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}" diff --git a/src/musicomp/transcode.py b/src/musicomp/transcode.py new file mode 100644 index 0000000..7ccd74b --- /dev/null +++ b/src/musicomp/transcode.py @@ -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}")