From ec00c483f318688515b071356fe79638a182af30 Mon Sep 17 00:00:00 2001 From: ZXED Date: Sat, 8 May 2021 23:11:50 +0300 Subject: [PATCH] Initial commit --- .github/workflows/main.yaml | 121 +++++ .gitignore | 5 + CHANGELOG.md | 6 + COPYING | 674 +++++++++++++++++++++++++ Cargo.lock | 972 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 40 ++ README.md | 196 ++++++++ build-unix.sh | 4 + build-windows.ps1 | 6 + build.rs | 6 + src/args.rs | 371 ++++++++++++++ src/concurrent_map.rs | 79 +++ src/convert.rs | 277 ++++++++++ src/cue.rs | 151 ++++++ src/files.rs | 123 +++++ src/main.rs | 138 +++++ src/meta.rs | 317 ++++++++++++ src/pics.rs | 83 +++ 18 files changed, 3569 insertions(+) create mode 100644 .github/workflows/main.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 COPYING create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100755 build-unix.sh create mode 100644 build-windows.ps1 create mode 100644 build.rs create mode 100644 src/args.rs create mode 100644 src/concurrent_map.rs create mode 100644 src/convert.rs create mode 100644 src/cue.rs create mode 100644 src/files.rs create mode 100644 src/main.rs create mode 100644 src/meta.rs create mode 100644 src/pics.rs diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..fcf7569 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,121 @@ +name: Build and release + + +on: + push: + branches: + - master + tags: + - v* + + +jobs: + build-linux: + runs-on: ubuntu-20.04 + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Build + run: ./build-unix.sh + + - name: Archive + run: tar -cf- -C target/release musbconv | xz -c9e - > musbconv-linux.tar.xz + + - name: Save + uses: actions/upload-artifact@v2 + with: + name: release-linux + path: "*.tar.xz" + + + build-windows: + runs-on: windows-2019 + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Build + run: .\build-windows.ps1 + + - name: Archive + run: Compress-Archive -Path target\release\musbconv.exe -DestinationPath musbconv-windows.zip + + - name: Save + uses: actions/upload-artifact@v2 + with: + name: release-windows + path: "*.zip" + + + build-macos: + runs-on: macos-10.15 + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Build + run: ./build-unix.sh + + - name: Archive + run: tar -cf- -C target/release musbconv | xz -c9e - > musbconv-macos.tar.xz + + - name: Save + uses: actions/upload-artifact@v2 + with: + name: release-macos + path: "*.tar.xz" + + + release: + if: startsWith(github.ref, 'refs/tags/') + + needs: + - build-linux + - build-windows + - build-macos + + runs-on: ubuntu-20.04 + steps: + - name: Version + id: version + run: echo ::set-output name=version::${GITHUB_REF#refs/tags/} + + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Notes + run: grep -Pzom1 "(?s)\n[##\s]*${{ steps.version.outputs.version }}.*?\n+.*?\K.*?(\n\n|$)" CHANGELOG.md | sed 's/[^[:print:]]//g' > RELEASE.md + + - name: Download + uses: actions/download-artifact@v2 + with: + path: artifacts + + - name: Rename + run: | + mv artifacts/release-linux/musbconv-linux.tar.xz artifacts/release-linux/musbconv-linux-${{ steps.version.outputs.version }}.tar.xz + mv artifacts/release-windows/musbconv-windows.zip artifacts/release-windows/musbconv-windows-${{ steps.version.outputs.version }}.zip + mv artifacts/release-macos/musbconv-macos.tar.xz artifacts/release-macos/musbconv-macos-${{ steps.version.outputs.version }}.tar.xz + + - name: Release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + files: | + artifacts/release-linux/*.tar.xz + artifacts/release-windows/*.zip + artifacts/release-macos/*.tar.xz + body_path: RELEASE.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d954f40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +/lib/ +.* +!.gitignore +!.github/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a33b5a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# musbconv - CHANGELOG + + +## v0.1.0 (May 9, 2021) + +- Initial release diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..215d838 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,972 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "any_ascii" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" + +[[package]] +name = "anyhow" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bitvec" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ba35e9565969edb811639dbebfe34edc0368e472c5018474c8eb2543397f81" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "byteorder" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b" + +[[package]] +name = "cc" +version = "1.0.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "const_fn" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd51eab21ab4fd6a3bf889e2d0958c0a6e3a61ad04260325e919e652a2a62826" + +[[package]] +name = "crossbeam-channel" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca26ee1f8d361640700bde38b2c37d8c22b3ce2d360e1fc1c74ea4b0aa7d775" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94af6efb46fef72616855b036a624cf27ba656ffc9be1b9a3c931cfc7749a9a9" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1aaa739f95311c2c7887a76863f500026092fb1dce0161dab577e559ef3569d" +dependencies = [ + "cfg-if", + "const_fn", + "crossbeam-utils", + "lazy_static", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d96d1e189ef58269ebe5b97953da3274d83a93af647c2ddd6f9dab28cedb8d" +dependencies = [ + "autocfg", + "cfg-if", + "lazy_static", +] + +[[package]] +name = "cuna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63836e1fac6e3d520d7fb87b0b5fc986fd177aa70ac32c14feeca8aebd245601" +dependencies = [ + "nom", + "thiserror", +] + +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + +[[package]] +name = "enum-iterator" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c79a6321a1197d7730510c7e3f6cb80432dfefecb32426de8cea0aa19b4bb8d7" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e94aa31f7c0dc764f57896dc615ddd76fc13b0d5dca7eb6cc5e018a5a09ec06" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + +[[package]] +name = "generic-array" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec" +dependencies = [ + "typenum", +] + +[[package]] +name = "getset" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b328c01a4d71d2d8173daa93562a73ab0fe85616876f02500f53d82948c504" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "git2" +version = "0.13.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d250f5f82326884bd39c2853577e70a121775db76818ffa452ed1e80de12986" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "handlebars" +version = "3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4498fc115fa7d34de968184e473529abb40eeb6be8bc5f7faba3d08c316cb3e3" +dependencies = [ + "log", + "pest", + "pest_derive", + "quick-error", + "serde", + "serde_json", +] + +[[package]] +name = "hermit-abi" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" +dependencies = [ + "libc", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "jobserver" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c71313ebb9439f74b00d9d2dcec36440beaf57a6aa0623068441dd7cd81a7f2" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lexical-core" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "ryu", + "static_assertions", +] + +[[package]] +name = "lexical-sort" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09e4591611e231daf4d4c685a66cb0410cc1e502027a20ae55f2bb9e997207a" +dependencies = [ + "any_ascii", +] + +[[package]] +name = "libc" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb" + +[[package]] +name = "libgit2-sys" +version = "0.12.18+1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da6a42da88fc37ee1ecda212ffa254c25713532980005d5f7c0b0fbe7e6e885" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113192b08db8f38796c4e85c39e960c145965140e918018bcde1952429655" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + +[[package]] +name = "memoffset" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157b4208e3059a8f9e78d559edc658e13df41410cb3ae03979c83130067fdd87" +dependencies = [ + "autocfg", +] + +[[package]] +name = "musbconv" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "cuna", + "handlebars", + "lazy_static", + "lexical-sort", + "path-dedot", + "rayon", + "regex", + "sanitize-filename", + "serde", + "serde_json", + "shell-words", + "vergen", + "which", +] + +[[package]] +name = "nom" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" +dependencies = [ + "bitvec", + "funty", + "lexical-core", + "memchr", + "version_check", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0" + +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + +[[package]] +name = "path-dedot" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a14ca47b49e6abd75cf68db85e1e161d9f2b675716894a18af0e9add0266b26" +dependencies = [ + "once_cell", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1", +] + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quick-error" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ac73b1112776fc109b2e61909bc46c7e1bf0d7f690ffb1676553acce16d5cda" + +[[package]] +name = "quote" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + +[[package]] +name = "rayon" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b0d8e0819fadc20c74ea8373106ead0600e3a67ef1fe8da56e39b9ae7275674" +dependencies = [ + "autocfg", + "crossbeam-deque", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab346ac5921dc62ffa9f89b7a773907511cdfa5490c572ae9be1be33e8afa4a" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "lazy_static", + "num_cpus", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "rustversion" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5d2a036dc6d2d8fd16fde3498b04306e29bd193bf306a57427019b823d5acd" + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "sanitize-filename" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf18934a12018228c5b55a6dae9df5d0641e3566b3630cb46cc55564068e7c2f" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "serde" +version = "1.0.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer", + "digest", + "fake-simd", + "opaque-debug", +] + +[[package]] +name = "shell-words" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "syn" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "typenum" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" + +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unicode-bidi" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "url" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "vcpkg" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "vergen" +version = "5.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf0b57f76a4f7e9673db1e7ffa4541d215ae8336fee45f5c1378bdeb22a0314" +dependencies = [ + "anyhow", + "cfg-if", + "chrono", + "enum-iterator", + "getset", + "git2", + "rustversion", + "thiserror", +] + +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "which" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" +dependencies = [ + "either", + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a5b60be --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "musbconv" +description = "Batch converter for audio files (uses ffmpeg)" +version = "0.1.0" +authors = ["Alexey Parfenov "] +edition = "2018" +license = "GPL-3.0-only" +readme = "README.md" +homepage = "https://github.com/alkatrazstudio/musbconv" +repository = "https://github.com/alkatrazstudio/musbconv" +keywords = ["audio", "converter", "music", "ffmpeg", "command-line"] +categories = ["multimedia::encoding", "command-line-utilities"] +publish = false + +[dependencies] +chrono = "0.4.19" +clap = "2.33.3" +cuna = "0.6.0" +handlebars = "3.5.5" +lazy_static = "1.4.0" +lexical-sort = "0.3.1" +path-dedot = "3.0.12" +rayon = "1.5.0" +regex = "1.5.4" +sanitize-filename = "0.3.0" +serde = { version = "1.0.125", features = ["derive"] } +serde_json = "1.0.64" +shell-words = "1.0.0" +which = "4.1.0" + +[build-dependencies] +vergen = { version = "5.1.5", default-features = false, features = ["build", "git"] } + +[profile.release] +lto = true +panic = "abort" +codegen-units = 1 + +[profile.dev] +codegen-units = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5c6e92 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# musbconv + +Performs a batch conversion between audio formats using ffmpeg. +Uses multiple threads if possible. +Supports CUE sheets and album art. + + +## Usage + +``` +USAGE: + musbconv [OPTIONS] --filename-template --input-dir ... --output-dir [--] [FFMPEG_OPTIONS]... + +FLAGS: + -h, --help + Prints help information + + -V, --version + Prints version information + + +OPTIONS: + --input-dir ... + Directory to search for audio files. + This directory will be searched recursively. + Only files with INPUT_EXT extensions will be considered. + This option can be specified multiple times. + --output-dir + Base directory for writing the converted files. + All converted output files will be located under this directory. + The actual location of the file depends on a FILENAME_TEMPLATE. + --filename-template + Template for the output filename inside OUTPUT_DIR. + The template is in Handlebars format (https://handlebarsjs.com). + Supported placeholders: + {{title}} - track title (if empty: defaults to {{file_base}}) + {{album}} - album name (if empty: defaults to {{dir_name}}) + {{artist}} - artist (if empty: defaults to {{author}} or {{performer}}) + {{catalog_number}} - catalog number + {{author}} - track author (if empty: defaults to {{artist}} or {{performer}}) + {{comment}} - comment + {{composer}} - composer (if empty: defaults to {{songwriter}}, {{lyricist}} or {{artist}}) + {{lyricist}} - lyricist (if empty: defaults to {{songwriter}}, {{composer}} or {{artist}}) + {{songwriter}} - songwriter (if empty: defaults to {{composer}}, {{lyricist}} or {{artist}}) + {{date}} - track/album date ((if empty: defaults to {{date}}) + {{disc}} - disc number + {{discs}} - total number of discs + {{disc_id}} - disc ID + {{track}} - track number (can be taken from the track itself or its cue-sheet) + {{tracks}} - number of tracks in the album (can be taken from the track itself or its cue-sheet) + {{genre}} - genre + {{label}} - music label + {{performer}} - performer + {{publisher}} - publisher + {{year}} - year (if empty: defaults to {{date}} if it starts with 4 digits) + {{file_name}} - input file name with the extension, but without the directory path + {{dir_name}} - directory name (without parent directories) + {{file_base}} - input file name without the extension + {{file_ext}} - file extension without a leading dot + All values in these placeholders will be present, but some of them may be empty strings. + The values will be sanitized for a safe usage in a file paths + and also directory separators will be removed. + --cover-ext + Comma-separated list of file extensions of cover art images. + The leading dot must not be specified. + The file extensions are case-insensitive. + You can specify an empty string as one of the extensions to load cover art from files without extensions. + An empty string (--cover-ext="") will mean load only files without extensions. + Only formats supported by ffmpeg will be processed. + [default: jpeg,jpg,png,gif] + --cover-name + Comma-separated list of file names of cover art images. + The file names do not include extensions (which are specified via --cover-ext). + The file names are case-insensitive. + Set to an empty string (--cover-name="") to disable loading cover art from files. + [default: folder,cover,album,albumartsmall,thumb,front,scan] + --dry-run + Dry-run. + Do not write anything to the disk, + just list the files that will be generated. + [default: n] [possible values: y, n] + --ffmpeg-bin + Path for ffmpeg program. + If not specified then ffmpeg is searched in PATH. + --ffprobe-bin + Path for ffprobe program. + If not specified then ffprobe is searched in PATH. + --input-ext + Comma-separated list of file extensions to search for. + Only the files with these extensions will be converted. + The list is case-insensitive. + Not all output formats may be supported by ffmpeg. + Run "ffmpeg -formats" to show a list of the supported formats + (search for "D"-formats). + [default: flac,wv,m4a] + --max-pic-height + Maximum height for a cover art in pixels. + The aspect ratio of the cover art will be preserved. + Must be in range of 1-5000. + [default: 500] + --max-pic-width + Maximum width for a cover art in pixels. + The aspect ratio of the cover art will be preserved. + Must be in range of 1-5000. + [default: 500] + --min-track-number-digits + Minimum number of digits for a resulting track number string. + This affects {{track}} and {{tracks}} placeholders in FILENAME_TEMPLATE but not the file tags. + The track number will be padded with zeroes if the number of digits is less than specified. + The resulting number of digits will be picked as maximum between + --min-track-number-digits and the original number of digits in {{tracks}}. + Examples: + a) if the original file has {{track}}="42" and {{tracks}}=50, + then --min-track-number-digits=3 will make {{track}}="042" and {{tracks}}=050. + b) if the original file has {{track}}="13" and {{tracks}}=150, + then --min-track-number-digits=1 will make {{track}}="013" and {{tracks}}=150. + Must be in range of 1-10. + [default: 2] + --output-ext + Extension for the output filename. + The extension also defines the format (e.g. mp3, ogg). + Some formats have predefined ffmpeg settings: + - MP3: -b:a 320k -write_id3v2 1 -id3v2_version 4 + - OGG: -b:a 320k + The extension/format name is case-insensitive. + Not all output formats may be supported by ffmpeg. + Run "ffmpeg -formats" to show a list of the supported formats + (search for "E"-formats). + [default: mp3] + --overwrite + Overwrite existing files. + y - overwrite the file if it already exists. + n - if the output file already exists then count it as an error. + [default: n] [possible values: y, n] + --pic-quality + Quality for a cover art. + Only applies when the cover art is bigger than the allowed dimensions + and needs to be re-encoded. + 1 - max quality + 31 - lowest quality + [default: 2] + --threads + Number of threads to simultaneously run ffmpeg in. + Must be between 0 and 1024. + If not specified or zero then the number of threads is chosen automatically. + [default: 0] + --use-embed-pic + Use and/or preserve the embedded album art if possible. + If set to "n" then the embedded album art will be always ignored, + and external images will be used instead. + [default: y] [possible values: y, n] + +ARGS: + ... + Additional ffmpeg options. + It's better to specify these options after a "--". + Example: musbconv ... -- -b:a 128k + +EXAMPLES: + + a) Simple. Put the converted files into folders named after the artists and the albums. + + musbconv --input-dir=/home/user/Music/flac --output-dir=/home/user/Music/mp3 \ + --filename-template="{{artist}}/{{year}} - {{album}}/{{track}}. {{title}}" + + b) Complex. Specify some custom musbconv options, ffmpeg options, + process files from multiple directories, + use a template with conditional statements and perform a dry-run. + + musbconv --input-dir=flac_folder1 --input-dir=flac_folder2 --output-dir=ogg_folder \ + --filename-template="{{artist}}/{{year}} - {{album}}/{{#if disc}}CD {{disc}}/{{/if}}{{track}}. {{title}}" \ + --input-ext=flac,wv --output-ext=ogg --overwrite=y --dry-run=y \ + --max-pic-width=256 --max-pic-height=256 --pic_quality=5 \ + -- -b:a 128k + + c) For Windows. Specify custom path for ffmpeg and ffprobe. + Note: on Windows you need to use either \\ or / as a directory separator inside the FILENAME_TEMPLATE. + + musbconv.exe --ffmpeg-bin=C:\Downloads\ffmpeg\bin\ffmpeg.exe --ffprobe-bin=C:\Downloads\ffmpeg\bin\ffprobe.exe \ + --input-dir="C:\Users\user\Music\flac music" --output-dir="C:\Users\user\Music\mp3 music" \ + --filename-template="{{artist}}\\{{year}} - {{album}}\\{{track}}. {{title}}" +``` + + +## Minimum system requirements + +- Ubuntu 20.04 (x86_64) +- Windows 10 version 1909 (x86_64) +- macOS 10.15 Catalina (x86_64) + +Also, you need `ffmpeg` and `ffprobe` installed. + + +## License + +GPLv3 diff --git a/build-unix.sh b/build-unix.sh new file mode 100755 index 0000000..5973298 --- /dev/null +++ b/build-unix.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname -- "${BASH_SOURCE[0]}")" +RUSTFLAGS="-C link-arg=-s" cargo build --release diff --git a/build-windows.ps1 b/build-windows.ps1 new file mode 100644 index 0000000..e154876 --- /dev/null +++ b/build-windows.ps1 @@ -0,0 +1,6 @@ +Push-Location "$PSScriptRoot" +try { + cargo build --release +} finally { + Pop-Location +} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..79d9509 --- /dev/null +++ b/build.rs @@ -0,0 +1,6 @@ +use vergen::{Config, vergen}; +use std::error::Error; + +fn main() -> Result<(), Box> { + return Ok(vergen(Config::default())?); +} diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..bb819be --- /dev/null +++ b/src/args.rs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use clap::{App, Arg, ErrorKind}; +use chrono::{DateTime}; +use std::error::Error; +use std::io::BufWriter; +use std::process::exit; + +pub struct AppArgs { + pub input_dirs: Vec, + pub output_dir: String, + pub filename_template: String, + pub dry_run: bool, + pub input_exts: Vec, + pub output_ext: String, + pub overwrite: bool, + pub ffmpeg_opts: Vec, + pub max_pic_width: u16, + pub max_pic_height: u16, + pub pic_quality: u8, + pub use_embed_pic: bool, + pub ffmpeg_bin: Option, + pub ffprobe_bin: Option, + pub threads_count: usize, + pub cover_names: Vec, + pub cover_exts: Vec, + pub min_track_number_digits: u8 +} + +fn validate_num_func(min: i64, max: i64) -> impl Fn(String) -> Result<(), String> { + return move |v| { + return match v.parse::() { + Ok(i) => { + if i < min || i > max { + return Err(format!("The number must be between {} and {}, but got {}.", min, max, i)); + } + return Ok(()); + } + Err(_) => Err(format!("\"{}\" can't be parsed as a number.", v)) + }; + }; +} + +fn opt_string_vec(opt: Option<&str>) -> Vec { + let parts = opt.unwrap_or_default(); + let parts = parts.split(',').map(|part| part.to_lowercase().trim().to_string()).collect::>(); + return parts; +} + +pub fn parse_cli_args() -> Result, Box> { + let v = "v".to_owned() + env!("CARGO_PKG_VERSION"); + let ts = DateTime::parse_from_rfc3339(env!("VERGEN_BUILD_TIMESTAMP"))?; + let day = ts.format("%e").to_string().trim_start().to_string(); + let ts_str = ts.format(format!("%B {}, %Y", &day).as_str()).to_string(); + let git_hash = &env!("VERGEN_GIT_SHA"); + let about = format!("\n\ + Performs a batch conversion between audio formats using ffmpeg.\n\ + Uses multiple threads if possible.\n\ + Supports CUE sheets and album art.\n\ + \n\ + Project homepage: https://github.com/alkatrazstudio/musbconv\n\ + License: GPLv3\n\ + Build date: {}\n\ + Git commit: {}\n\ + Author: Alexey Parfenov (a.k.a. ZXED) \n\ + Author homepage: https://alkatrazstudio.net", + &ts_str, git_hash); + + let mut app = App::new("musbconv") + .long_about(about.as_str()) + .version(v.as_str()) + + .arg(Arg::with_name("INPUT_DIR") + .long("input-dir") + .long_help("\ + Directory to search for audio files.\n\ + This directory will be searched recursively.\n\ + Only files with INPUT_EXT extensions will be considered.\n\ + This option can be specified multiple times.\n") + .required(true) + .multiple(true) + .number_of_values(1) + .empty_values(false) + .display_order(0)) + + .arg(Arg::with_name("OUTPUT_DIR") + .long("output-dir") + .long_help("\ + Base directory for writing the converted files.\n\ + All converted output files will be located under this directory.\n\ + The actual location of the file depends on a FILENAME_TEMPLATE.\n") + .required(true) + .empty_values(false) + .display_order(1)) + + .arg(Arg::with_name("FILENAME_TEMPLATE") + .long("filename-template") + .long_help("\ + Template for the output filename inside OUTPUT_DIR.\n\ + The template is in Handlebars format (https://handlebarsjs.com).\n\ + Supported placeholders:\n\ + \x20 {{title}} - track title (if empty: defaults to {{file_base}})\n\ + \x20 {{album}} - album name (if empty: defaults to {{dir_name}})\n\ + \x20 {{artist}} - artist (if empty: defaults to {{author}} or {{performer}})\n\ + \x20 {{catalog_number}} - catalog number\n\ + \x20 {{author}} - track author (if empty: defaults to {{artist}} or {{performer}})\n\ + \x20 {{comment}} - comment\n\ + \x20 {{composer}} - composer (if empty: defaults to {{songwriter}}, {{lyricist}} or {{artist}})\n\ + \x20 {{lyricist}} - lyricist (if empty: defaults to {{songwriter}}, {{composer}} or {{artist}})\n\ + \x20 {{songwriter}} - songwriter (if empty: defaults to {{composer}}, {{lyricist}} or {{artist}})\n\ + \x20 {{date}} - track/album date ((if empty: defaults to {{date}})\n\ + \x20 {{disc}} - disc number\n\ + \x20 {{discs}} - total number of discs\n\ + \x20 {{disc_id}} - disc ID\n\ + \x20 {{track}} - track number (can be taken from the track itself or its cue-sheet)\n\ + \x20 {{tracks}} - number of tracks in the album (can be taken from the track itself or its cue-sheet)\n\ + \x20 {{genre}} - genre\n\ + \x20 {{label}} - music label\n\ + \x20 {{performer}} - performer\n\ + \x20 {{publisher}} - publisher\n\ + \x20 {{year}} - year (if empty: defaults to {{date}} if it starts with 4 digits)\n\ + \x20 {{file_name}} - input file name with the extension, but without the directory path\n\ + \x20 {{dir_name}} - directory name (without parent directories)\n\ + \x20 {{file_base}} - input file name without the extension\n\ + \x20 {{file_ext}} - file extension without a leading dot\n\ + All values in these placeholders will be present, but some of them may be empty strings.\n\ + The values will be sanitized for a safe usage in a file paths\n\ + and also directory separators will be removed.\n") + .required(true) + .empty_values(false) + .display_order(2)) + + .arg(Arg::with_name("FFMPEG_OPTIONS") + .long_help("\ + Additional ffmpeg options.\n\ + It's better to specify these options after a \"--\".\n\ + Example: musbconv ... -- -b:a 128k\n") + .multiple(true)) + + .arg(Arg::with_name("DRY_RUN") + .long("dry-run") + .long_help("\ + Dry-run.\n\ + Do not write anything to the disk,\n\ + just list the files that will be generated.\n") + .possible_values(&["y", "n"]) + .value_name("y|n") + .default_value("n")) + + .arg(Arg::with_name("INPUT_EXT") + .long("input-ext") + .long_help( "\ + Comma-separated list of file extensions to search for.\n\ + Only the files with these extensions will be converted.\n\ + The list is case-insensitive.\n\ + Not all output formats may be supported by ffmpeg.\n\ + Run \"ffmpeg -formats\" to show a list of the supported formats\n\ + (search for \"D\"-formats).\n") + .default_value("flac,wv,m4a") + .empty_values(false) + .value_name("ext1,ext2,...")) + + .arg(Arg::with_name("OUTPUT_EXT") + .long("output-ext") + .long_help( "\ + Extension for the output filename.\n\ + The extension also defines the format (e.g. mp3, ogg).\n\ + Some formats have predefined ffmpeg settings:\n\ + - MP3: -b:a 320k -write_id3v2 1 -id3v2_version 4\n\ + - OGG: -b:a 320k\n\ + The extension/format name is case-insensitive.\n\ + Not all output formats may be supported by ffmpeg.\n\ + Run \"ffmpeg -formats\" to show a list of the supported formats\n\ + (search for \"E\"-formats).\n") + .default_value("mp3") + .empty_values(false) + .value_name("ext")) + + .arg(Arg::with_name("OVERWRITE") + .long("overwrite") + .long_help("\ + Overwrite existing files.\n\ + y - overwrite the file if it already exists.\n\ + n - if the output file already exists then count it as an error.\n") + .possible_values(&["y", "n"]) + .value_name("y|n") + .default_value("n")) + + .arg(Arg::with_name("MAX_PIC_WIDTH") + .long("max-pic-width") + .long_help("\ + Maximum width for a cover art in pixels.\n\ + The aspect ratio of the cover art will be preserved.\n\ + Must be in range of 1-5000.\n") + .value_name("WIDTH") + .default_value("500") + .validator(validate_num_func(1, 5000))) + + .arg(Arg::with_name("MAX_PIC_HEIGHT") + .long("max-pic-height") + .long_help("\ + Maximum height for a cover art in pixels.\n\ + The aspect ratio of the cover art will be preserved.\n\ + Must be in range of 1-5000.\n") + .value_name("HEIGHT") + .default_value("500") + .validator(validate_num_func(1, 5000))) + + .arg(Arg::with_name("PIC_QUALITY") + .long("pic-quality") + .long_help("\ + Quality for a cover art.\n\ + Only applies when the cover art is bigger than the allowed dimensions\n\ + and needs to be re-encoded.\n\ + 1 - max quality\n\ + 31 - lowest quality\n") + .value_name("QUALITY") + .default_value("2") + .validator(validate_num_func(1, 31))) + + .arg(Arg::with_name("USE_EMBED_PIC") + .long("use-embed-pic") + .long_help("\ + Use and/or preserve the embedded album art if possible.\n\ + If set to \"n\" then the embedded album art will be always ignored,\n\ + and external images will be used instead.\n") + .possible_values(&["y", "n"]) + .value_name("y|n") + .default_value("y")) + + .arg(Arg::with_name("COVER_NAME") + .long("cover-name") + .long_help("\ + Comma-separated list of file names of cover art images.\n\ + The file names do not include extensions (which are specified via --cover-ext).\n\ + The file names are case-insensitive.\n\ + Set to an empty string (--cover-name=\"\") to disable loading cover art from files.\n") + .value_name("FILENAME") + .default_value("folder,cover,album,albumartsmall,thumb,front,scan")) + + .arg(Arg::with_name("COVER_EXT") + .long("cover-ext") + .long_help("\ + Comma-separated list of file extensions of cover art images.\n\ + The leading dot must not be specified.\n\ + The file extensions are case-insensitive.\n\ + You can specify an empty string as one of the extensions to load cover art from files without extensions.\n\ + An empty string (--cover-ext=\"\") will mean load only files without extensions.\n\ + Only formats supported by ffmpeg will be processed.\n") + .value_name("FILENAME") + .default_value("jpeg,jpg,png,gif")) + + .arg(Arg::with_name("MIN_TRACK_NUMBER_DIGITS") + .long("min-track-number-digits") + .long_help("\ + Minimum number of digits for a resulting track number string.\n\ + This affects {{track}} and {{tracks}} placeholders in FILENAME_TEMPLATE but not the file tags.\n\ + The track number will be padded with zeroes if the number of digits is less than specified.\n\ + The resulting number of digits will be picked as maximum between\n\ + --min-track-number-digits and the original number of digits in {{tracks}}.\n\ + Examples:\n\ + \x20 a) if the original file has {{track}}=\"42\" and {{tracks}}=50,\n\ + \x20 then --min-track-number-digits=3 will make {{track}}=\"042\" and {{tracks}}=050.\n\ + \x20 b) if the original file has {{track}}=\"13\" and {{tracks}}=150,\n\ + \x20 then --min-track-number-digits=1 will make {{track}}=\"013\" and {{tracks}}=150.\n\ + Must be in range of 1-10.\n") + .value_name("MIN_DIGITS") + .default_value("2") + .validator(validate_num_func(1, 10))) + + .arg(Arg::with_name("FFMPEG_BIN") + .long("ffmpeg-bin") + .long_help("\ + Path for ffmpeg program.\n\ + If not specified then ffmpeg is searched in PATH.\n") + .value_name("PATH_TO_FFMPEG_BINARY")) + + .arg(Arg::with_name("FFPROBE_BIN") + .long("ffprobe-bin") + .long_help("\ + Path for ffprobe program.\n\ + If not specified then ffprobe is searched in PATH.\n") + .value_name("PATH_TO_FFPROBE_BINARY")) + + .arg(Arg::with_name("THREADS") + .long("threads") + .long_help("\ + Number of threads to simultaneously run ffmpeg in.\n\ + Must be between 0 and 1024.\n\ + If not specified or zero then the number of threads is chosen automatically.\n") + .default_value("0") + .validator(validate_num_func(0, 1024))) + + .after_help( + "EXAMPLES:\n\ + \n\ + \x20 a) Simple. Put the converted files into folders named after the artists and the albums.\n\ + \n\ + \x20 musbconv --input-dir=/home/user/Music/flac --output-dir=/home/user/Music/mp3 \\\n\ + \x20 --filename-template=\"{{artist}}/{{year}} - {{album}}/{{track}}. {{title}}\"\n\ + \n\ + \x20 b) Complex. Specify some custom musbconv options, ffmpeg options,\n\ + \x20 process files from multiple directories, \n\ + \x20 use a template with conditional statements and perform a dry-run.\n\ + \n\ + \x20 musbconv --input-dir=flac_folder1 --input-dir=flac_folder2 --output-dir=ogg_folder \\\n\ + \x20 --filename-template=\"{{artist}}/{{year}} - {{album}}/{{#if disc}}CD {{disc}}/{{/if}}{{track}}. {{title}}\" \\\n\ + \x20 --input-ext=flac,wv --output-ext=ogg --overwrite=y --dry-run=y \\\n\ + \x20 --max-pic-width=256 --max-pic-height=256 --pic_quality=5 \\\n\ + \x20 -- -b:a 128k\n\ + \n\ + \x20 c) For Windows. Specify custom path for ffmpeg and ffprobe.\n\ + \x20 Note: on Windows you need to use either \\\\ or / as a directory separator inside the FILENAME_TEMPLATE. + \n\ + \x20 musbconv.exe --ffmpeg-bin=C:\\Downloads\\ffmpeg\\bin\\ffmpeg.exe --ffprobe-bin=C:\\Downloads\\ffmpeg\\bin\\ffprobe.exe \\\n\ + \x20 --input-dir=\"C:\\Users\\user\\Music\\flac music\" --output-dir=\"C:\\Users\\user\\Music\\mp3 music\" \\\n\ + \x20 --filename-template=\"{{artist}}\\\\{{year}} - {{album}}\\\\{{track}}. {{title}}\""); + + let mut buf = BufWriter::new(Vec::new()); + app.write_long_help(&mut buf)?; + let help_bytes = buf.into_inner()?; + let help_str = String::from_utf8(help_bytes)?; + + let matches = app.get_matches_safe(); + + match matches { + Ok(matches) => { + let input_exts = opt_string_vec(matches.value_of("INPUT_EXT")); + let cover_names = opt_string_vec(matches.value_of("COVER_NAME")); + let cover_exts = opt_string_vec(matches.value_of("COVER_EXT")); + + let ffmpeg_opts = matches.values_of("FFMPEG_OPTIONS").unwrap_or_default().map(|s| s.to_string()).collect(); + + return Ok(Some(AppArgs { + input_dirs: matches.values_of("INPUT_DIR").unwrap().map(|s| s.to_owned()).collect(), + output_dir: matches.value_of("OUTPUT_DIR").unwrap().to_string(), + filename_template: matches.value_of("FILENAME_TEMPLATE").unwrap().to_string(), + dry_run: matches.value_of("DRY_RUN").unwrap() == "y", + input_exts, + output_ext: matches.value_of("OUTPUT_EXT").unwrap().to_lowercase(), + overwrite: matches.value_of("OVERWRITE").unwrap() == "y", + ffmpeg_opts, + max_pic_height: matches.value_of("MAX_PIC_HEIGHT").unwrap().parse::()?, + max_pic_width: matches.value_of("MAX_PIC_WIDTH").unwrap().parse::()?, + pic_quality: matches.value_of("PIC_QUALITY").unwrap().parse::()?, + use_embed_pic: matches.value_of("USE_EMBED_PIC").unwrap() == "y", + ffmpeg_bin: matches.value_of("FFMPEG_BIN").map(|s| s.to_string()), + ffprobe_bin: matches.value_of("FFPROBE_BIN").map(|s| s.to_string()), + threads_count: matches.value_of("THREADS").unwrap().parse::()?, + cover_names, + cover_exts, + min_track_number_digits: matches.value_of("MIN_TRACK_NUMBER_DIGITS").unwrap().parse::()?, + })); + } + Err(e) => match e.kind { + ErrorKind::HelpDisplayed => { + println!("{}", &help_str); + return Ok(None); + }, + ErrorKind::VersionDisplayed => { + println!(); + return Ok(None); + } + _ => { + println!("{}", e.message); + exit(1); + } + } + } +} diff --git a/src/concurrent_map.rs b/src/concurrent_map.rs new file mode 100644 index 0000000..96e0a1c --- /dev/null +++ b/src/concurrent_map.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use std::sync::{Arc, RwLock}; +use std::collections::HashMap; +use std::hash::Hash; + +type ConcurrentMapOptValue = Option; +type ConcurrentMapOptValueLock = RwLock>; +type ConcurrentMapSafeLockedOptValue = Arc>; +type ConcurrentMapSafeHash = HashMap>; +type ConcurrentMapLockedSafeHash = RwLock>; + +pub struct ConcurrentMap(Arc>); + +impl ConcurrentMap { + pub fn new() -> Self + { + return Self(Arc::new(RwLock::new(HashMap::new()))); + } + + fn get_from_map(map: &HashMap>>>, key: &K) -> Option + { + if let Some(res) = map.get(key) { + if let Ok(res_guard) = res.read() { + if let Some(s) = &*res_guard { + return Some(s.clone()); + } + } + } + return None; + } + + pub fn get(&self, key: &K) -> Option { + if let Ok(guard) = self.0.read() { + let map = &*guard; + return Self::get_from_map(map, key); + } + return None; + } + + pub fn set(&self, key: &K, val_func: F) -> Option where F:FnOnce() -> V { + let i = Arc::new(RwLock::new(None)); + + let mut res_lock = Option::None; + if let Ok(mut lock) = self.0.write() { + let map = &mut *lock; + if let Some(res) = Self::get_from_map(&map, &key) { + return Some(res); + } + if let Ok(lock) = i.write() { + res_lock = Some(lock); + map.insert(key.clone(), i.clone()); + } + }; + + if let Some(mut lock_guard) = res_lock { + let res = val_func(); + let ret = Some(res.clone()); + *lock_guard = Some(res); + return ret; + } + + panic!(); + } + + pub fn set_if_not_exists(&self, key: &K, val_func: F) -> Option where F:FnOnce() -> V { + if let Some(res) = self.get(key) { + return Some(res); + } + return self.set(key, val_func); + } +} + +impl Clone for ConcurrentMap { + fn clone(&self) -> Self { + return Self(self.0.clone()) + } +} diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..9b7c31d --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use crate::cue::CueInfo; +use crate::pics::{PicsMap, find_cover_in_dir, ffmpeg_conv_pic_args}; +use crate::args::AppArgs; +use std::error::Error; +use std::path::Path; +use crate::meta::{extract_meta, fill_fallback_tags, sanitize_tags, MetaTags}; +use handlebars::Handlebars; +use std::process::Command; +use std::io::Write; +use path_dedot::ParseDot; +use crate::Progs; +use std::cmp::max; + +pub struct Item { + pub filename: String, + pub basename: String, + pub index: usize, + pub total: usize, + pub cue: Option +} + +impl Item { + pub fn print_info(&self, cat: &str, info: &str) { + println!("[{}/{}:{}] {}", self.index + 1, self.total, cat, info); + } + + fn print_args(&self, cmd: &str, args: &[String]) { + let args = shell_words::join(args); + self.print_info("CMD", &format!("{} {}", cmd, args)); + } +} + +fn sanitize_filename(filename: &str) -> Result> { + let path = String::from("/..///") + filename; + let path = Path::new(&path).parse_dot()?; + let path = path.strip_prefix("/")?; + let path = path.to_str().ok_or("Can't convert path to string")?; + return Ok(path.to_string()); +} + +macro_rules! str_vec { + ($($x:expr),*) => (vec![$($x.to_string()),*]); +} + +fn add_meta(args: &mut Vec, val: &str, name: &str) { + if !val.is_empty() { + args.extend(str_vec![ + "-metadata", &format!("{}={}", name, val) + ]); + } +} + +fn render_template(template: &str, tags: &MetaTags) -> Result> { + let mut hb = Handlebars::new(); + hb.set_strict_mode(true); + hb.register_escape_fn(|s| s.into()); + + let result = hb.render_template(template, tags)?; + return Ok(result); +} + +pub fn validate_template(template: &str) -> Result<(), Box> { + let tags = MetaTags { + ..Default::default() + }; + if let Err(e) = render_template(template, &tags) { + return Err(format!("{}", e).into()); + } + + let tags = MetaTags { + title: "1".to_string(), + album: "1".to_string(), + artist: "1".to_string(), + catalog_number: "1".to_string(), + author: "1".to_string(), + comment: "1".to_string(), + composer: "1".to_string(), + lyricist: "1".to_string(), + songwriter: "1".to_string(), + date: "1".to_string(), + disc: "1".to_string(), + discs: "1".to_string(), + disc_id: "1".to_string(), + track: "1".to_string(), + tracks: "1".to_string(), + genre: "1".to_string(), + label: "1".to_string(), + performer: "1".to_string(), + publisher: "1".to_string(), + year: "1".to_string(), + file_name: "1".to_string(), + dir_name: "1".to_string(), + file_base: "1".to_string(), + file_ext: "1".to_string() + }; + if let Err(e) = render_template(template, &tags) { + return Err(format!("{}", e).into()); + } + + return Ok(()); +} + +pub fn conv_item(item: &Item, pics: &PicsMap, app_args: &AppArgs, progs: &Progs) -> Result> +{ + let input_filename = &item.filename; + item.print_info("INFO", &format!("processing {}", &input_filename)); + let canonical_path = Path::new(input_filename).parent() + .ok_or(format!("no parent for {}", input_filename))?.canonicalize()?; + let input_dir = canonical_path.to_str().ok_or("Can't get a string from the canonical path")?; + + let meta = extract_meta(input_filename, &item.cue, &progs.ffprobe_bin)?; + let mut tags = fill_fallback_tags(&meta.tags); + if !tags.tracks.is_empty() { + tags.tracks = format!("{:0>width$}", tags.tracks, width = app_args.min_track_number_digits as usize); + } + if !tags.track.is_empty() { + let tracks_digits_count = max(tags.tracks.len(), app_args.min_track_number_digits as usize); + tags.track = format!("{:0>width$}", tags.track, width = tracks_digits_count); + } + + let filename_tags = sanitize_tags(&tags); + + let filename = render_template(&app_args.filename_template, &filename_tags)?; + let filename = filename + "." + &app_args.output_ext; + let output_filename = sanitize_filename(&filename)?; + + let output_path = Path::new(&app_args.output_dir).join(&output_filename); + let output_path_str = output_path.to_str().ok_or("Can't convert path to string")?; + let dir_path = output_path.parent().ok_or(format!("no parent for {}", output_path_str))?; + + if !app_args.overwrite && output_path.exists() { + return Err(format!("file exists: {}", output_path_str).into()); + } + + if !app_args.dry_run { + std::fs::create_dir_all(dir_path)?; + } + + let mut args = str_vec![ + "-hide_banner", "-nostats", + "-loglevel", "warning", + "-y" + ]; + + let mut audio_args; + match app_args.output_ext.as_str() { + "mp3" => { + audio_args = str_vec![ + "-b:a", "320k", + "-write_id3v2", "1", + "-id3v2_version", "4" + ]; + } + "ogg" => { + audio_args = str_vec![ + "-b:a", "320k" + ]; + } + _ => audio_args = Vec::new() + } + + add_meta(&mut audio_args, &meta.tags.album, "album"); + add_meta(&mut audio_args, &meta.tags.composer, "composer"); + add_meta(&mut audio_args, &meta.tags.genre, "genre"); + add_meta(&mut audio_args, &meta.tags.title, "title"); + add_meta(&mut audio_args, &meta.tags.artist, "artist"); + add_meta(&mut audio_args, &meta.tags.performer, "performer"); + add_meta(&mut audio_args, &meta.tags.disc, "disc"); + add_meta(&mut audio_args, &meta.tags.publisher, "publisher"); + add_meta(&mut audio_args, &meta.tags.date, "date"); + add_meta(&mut audio_args, &meta.tags.year, "year"); + + if !meta.tags.track.is_empty() && !meta.tags.tracks.is_empty() { + add_meta(&mut audio_args, &(meta.tags.track + "/" + &meta.tags.tracks), "track"); + } else { + add_meta(&mut audio_args, &meta.tags.track, "track"); + } + + let start_str; + let duration_str; + if let Some(cue) = &item.cue { + start_str = format!("{:.3}", cue.start); + args.extend(str_vec![ + "-ss:a", &start_str + ]); + + duration_str = if let Some(duration) = cue.duration { + format!("{:.3}", duration) + } else { + String::default() + }; + + if !duration_str.is_empty() { + args.extend(str_vec![ + "-t:a", &duration_str + ]); + } + } + let output; + + args.extend(str_vec![ + "-i", &input_filename + ]); + if meta.has_pic && app_args.use_embed_pic { + args.extend(audio_args); + + let pic_args = ffmpeg_conv_pic_args(app_args); + args.extend(pic_args); + let mut args = args.iter().chain(&app_args.ffmpeg_opts).cloned().collect::>(); + args.push(output_path_str.to_string()); + + item.print_args(&progs.ffmpeg_bin, &args); + if app_args.dry_run { + output = None; + } else { + output = Some(Command::new(&progs.ffmpeg_bin).args(args).output()?); + } + } else { + let output_pic_data; + if let Some(input_pic_filename) = find_cover_in_dir(input_dir, &app_args.cover_names, &app_args.cover_exts) { + output_pic_data = pics.conv_pic_if_needed(&input_pic_filename, &app_args, &progs); + if output_pic_data == None { + return Err(format!("can't convert: {}", &input_pic_filename).into()); + } + } else { + output_pic_data = None; + } + + if let Some(output_pic_data) = output_pic_data { + args.extend(str_vec![ + "-i", "-" + ]); + args.extend(audio_args); + args.extend(str_vec![ + "-map", "0:a", "-map", "1:v", + "-c:v", "copy", + "-metadata:s:v", "title=Album cover", "-metadata:s:v", "comment=Cover (front)" + ]); + let mut args = args.iter().chain(&app_args.ffmpeg_opts).cloned().collect::>(); + args.push(output_path_str.into()); + + item.print_args(&progs.ffmpeg_bin, &args); + if app_args.dry_run { + output = None; + } else { + let mut proc = Command::new(&progs.ffmpeg_bin).args(&args).stdin(std::process::Stdio::piped()).spawn()?; + if let Some(stdin) = proc.stdin.as_mut() { + stdin.write_all(&output_pic_data)?; + stdin.flush()?; + } + output = Some(proc.wait_with_output()?); + } + } else { + args.extend(audio_args); + let mut args = args.iter().chain(&app_args.ffmpeg_opts).cloned().collect::>(); + args.push(output_path_str.into()); + + item.print_args(&progs.ffmpeg_bin, &args); + if app_args.dry_run { + output = None; + } else { + output = Some(Command::new(&progs.ffmpeg_bin).args(args).output()?); + } + } + } + + if let Some(output) = output { + if output.status.code().ok_or("Cannot get the exit code")? != 0 { + return Err(std::str::from_utf8(&output.stderr)?.into()); + } + } + + return Ok(output_path_str.into()); +} diff --git a/src/cue.rs b/src/cue.rs new file mode 100644 index 0000000..fd388da --- /dev/null +++ b/src/cue.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use std::path::Path; +use cuna::Cuna; +use cuna::track::Track; +use regex::Regex; +use std::error::Error; +use std::fs::File; +use std::str::FromStr; +use std::io::Read; +use std::char::REPLACEMENT_CHARACTER; + +const CUE_FRAMES_IN_SECOND: u8 = 75; + +pub struct CueInfo { + pub start: f64, + pub duration: Option, + pub album: String, + pub title: String, + pub performer: String, + pub songwriter: String, + pub genre: String, + pub date: String, + pub disc_id: String, + pub track: String, + pub tracks: String, +} + +fn read_string_from_file(path: &Path) -> Result> { + let mut file = File::open(path)?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + let s = String::from_utf8_lossy(&buf); + let s = s.replace(REPLACEMENT_CHARACTER, ""); + return Ok(s); +} + +fn open_cue(path: &Path) -> Result> { + let s = read_string_from_file(path)?; + let rx = Regex::new(r"(?:^|\n)\s*FLAGS.*(?:\n|$)")?; + let s = rx.replace_all(&s, "\n").to_string(); + let cue = Cuna::from_str(&s)?; + return Ok(cue); +} + +pub fn find_cue_info(path: &Path) -> Vec { + let cue_filename = path.with_extension("cue"); + let mut infos = Vec::new(); + if cue_filename.exists() { + match open_cue(&cue_filename) { + Ok(cue) => { + if let Some(file) = cue.first_file() { + let max_track_index = max_track_index(&file.tracks); + for track in &file.tracks { + let next_track = track_by_index(&file.tracks, track.id() + 1); + if let Some(info) = cue_track_info(&track, next_track, max_track_index, &cue) { + infos.push(info); + } + } + } + }, + + Err(e) => println!("{}", e) + } + } + return infos; +} + +fn max_track_index(tracks: &[Track]) -> u8 { + let mut max_id = 0; + for track in tracks { + if track.id() > max_id { + max_id = track.id(); + } + } + return max_id; +} + +fn track_by_index(tracks: &[Track], id: u8) -> Option<&Track> { + for track in tracks { + if track.id() == id { + return Some(track); + } + } + return None; +} + +fn track_start(track: &Track) -> Option { + for i in &track.index { + if i.id() == 1 { + return Some(i.begin_time.as_frames()); + } + } + return None; +} + +fn opt_str(s: &[String], def: &str) -> String { + if let Some(s) = s.first() { + return s.to_owned(); + } + return def.into(); +} + +fn extract_comment(cd: &Cuna, tag: &str) -> String { + let rx_str = String::from(r"(?i)^") + ®ex::escape(tag) + r#"\s+(.+)"?$"#; + let rx = Regex::new(&rx_str).unwrap(); + for comment in &cd.comments.0 { + if let Some(m) = rx.captures(&comment) { + if let Some(m) = m.get(1) { + let s = m.as_str(); + if s.starts_with('"') && s.ends_with('"') && s.len() > 1 { + return s[1..s.len()-1].into(); + } + return s.into(); + } + } + } + + return Default::default(); +} + +fn cue_track_info(track: &Track, next_track: Option<&Track>, max_track_index: u8, cd: &Cuna) -> Option { + if let Some(start) = track_start(track) { + let mut duration = None; + if let Some(next_track) = next_track { + if let Some(next_start) = track_start(next_track) { + if next_start > start { + duration = Some(next_start - start); + } + } + } + + let duration = duration.map(|duration| f64::from(duration) / f64::from(CUE_FRAMES_IN_SECOND)); + + return Some(CueInfo { + start: f64::from(start) / f64::from(CUE_FRAMES_IN_SECOND), + duration, + album: opt_str(cd.title(), ""), + title: opt_str(track.title(), ""), + performer: opt_str(track.performer(), &opt_str(cd.performer(), "")), + songwriter: opt_str(track.songwriter(), &opt_str(cd.songwriter(), "")), + genre: extract_comment(cd, "GENRE"), + date: extract_comment(cd, "DATE"), + disc_id: extract_comment(cd, "DISCID"), + track: track.id().to_string().trim().to_string(), + tracks: max_track_index.to_string().trim().to_string() + }); + } + return None; +} diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..211ab40 --- /dev/null +++ b/src/files.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use crate::convert::Item; +use std::path::{Path, Component}; +use crate::cue::find_cue_info; +use lexical_sort::natural_lexical_only_alnum_cmp; +use std::path::Component::{Prefix, Normal}; +use std::error::Error; + +pub fn find_files(dirs: &[String], exts: &[String]) -> Result, Box> { + let mut items = Vec::new(); + + for dir in dirs { + let input_dir = Path::new(dir); + if !input_dir.exists() { + return Err(format!("not found: {}", dir).into()); + } + + if let Ok(entries) = input_dir.read_dir() { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + let path = entry.path(); + if let Some(filename) = path.to_str() { + if file_type.is_dir() { + let sub_items = find_files(&[filename.to_owned()], exts)?; + items.extend(sub_items); + } else if file_type.is_file() { + if let Some(ext) = path.extension() { + if let Some(ext) = ext.to_str() { + let ext = ext.to_lowercase(); + let ext = ext.to_string(); + if !exts.contains(&ext) { + continue; + } + } + } + if let Some(basename) = path.file_stem() { + if let Some(basename) = basename.to_str() { + let infos = find_cue_info(&path); + if infos.is_empty() { + items.push(Item { + filename: filename.to_string(), + basename: basename.to_string(), + index: 0, + total: 0, + cue: None + }); + } else { + for info in infos { + items.push(Item { + filename: filename.to_string(), + basename: basename.to_string(), + index: 0, + total: 0, + cue: Some(info) + }); + } + } + } + } + } + } + } + } + } + } + + items.sort_unstable_by(|a, b| natural_lexical_only_alnum_cmp(&a.basename, &b.basename)); + let n = items.len(); + for (i, item) in items.iter_mut().enumerate() { + item.index = i; + item.total = n; + } + + return Ok(items); +} + +fn component_name(component: &Component) -> String { + return match component { + Prefix(prefix) => prefix.as_os_str().to_str().unwrap_or_default().to_owned(), + Normal(s) => s.to_str().unwrap_or_default().to_string(), + _ => "".to_string() + } +} + +pub fn print_tree(base_dir: &str, filenames: &[&String]) { + println!(); + println!("{}", base_dir); + + let mut filenames = filenames.to_vec(); + filenames.sort(); + let base_len = base_dir.len(); + + let mut prev_components = Vec::new(); + for filename in filenames { + let filename = &filename[base_len..]; + let components = Path::new(filename).components().collect::>(); + let mut is_diff = false; + for (i, component) in components.iter().enumerate() { + let name= component_name(&component); + if !is_diff { + let prev_name = match prev_components.get(i) { + Some(c) => component_name(c), + None => String::default() + }; + + if name == prev_name { + continue; + } + + is_diff = true; + } + + for _ in 0..i { + print!(" "); + } + + println!("{}{}", std::path::MAIN_SEPARATOR, &name); + } + prev_components = components.clone(); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..afa41f9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +mod concurrent_map; +mod meta; +mod pics; +mod args; +mod cue; +mod convert; +mod files; + +use rayon::prelude::*; +use std::error::Error; +use crate::pics::PicsMap; +use crate::args::{parse_cli_args, AppArgs}; +use crate::convert::{Item, conv_item, validate_template}; +use crate::files::{find_files, print_tree}; +use std::path::Path; + +enum ItemResult { + Filename(String), + Error(String) +} + +pub struct Progs { + pub ffmpeg_bin: String, + pub ffprobe_bin: String +} + +fn run(items: &[Item], args: &AppArgs, progs: &Progs) -> Result, Box> +{ + rayon::ThreadPoolBuilder::new().num_threads(args.threads_count).build_global()?; + + let pics = PicsMap::new(); + let filenames = items.par_iter().map(|item| { + return match conv_item(&item, &pics, args, progs) { + Err(e) => { + item.print_info("ERR", &*e.to_string()); + return ItemResult::Error(e.to_string()); + }, + Ok(filename) => ItemResult::Filename(filename) + }; + }).collect(); + + return Ok(filenames); +} + +fn find_prog(name: &str, arg: &Option) -> Result> { + if let Some(a) = arg.clone() { + let path = Path::new(&a); + if !path.exists() { + return Err(format!("{} does not exists.", path.to_str().ok_or("Can't convert path to string")?).into()); + } + return Ok(path.to_str().ok_or("Can't convert path to string")?.to_string()); + } else { + match which::which(name) { + Ok(file_path) => { + return Ok(file_path.to_str().ok_or("Can't convert path to string")?.to_string()); + } + Err(_) => { + return Err(format!("{} is not found.", &name).into()); + } + } + } +} + +fn find_progs(args: &AppArgs) -> Result> { + return Ok(Progs { + ffmpeg_bin: find_prog("ffmpeg", &args.ffmpeg_bin)?, + ffprobe_bin: find_prog("ffprobe", &args.ffprobe_bin)? + }); +} + +fn main() -> Result<(), Box> { + let args = parse_cli_args()?; + if let Some(args) = args { + validate_template(&args.filename_template)?; + let progs = find_progs(&args)?; + + let items = find_files(&args.input_dirs, &args.input_exts)?; + let filenames = run(&items, &args, &progs)?; + let mut valid_filenames = Vec::new(); + let mut errs = Vec::new(); + + let n = filenames.len(); + + for a in 0..n { + let filename = &filenames[a]; + match filename { + ItemResult::Filename(filename) => { + let mut exists = false; + for b in (a + 1)..n { + if let ItemResult::Filename(other_filename) = &filenames[b] { + if filename.eq(other_filename) { + exists = true; + errs.push(format!("{}: resolves to {} just as {}", &items[a].filename, &filename, &items[b].filename)); + break; + } + } + } + + if !exists { + valid_filenames.push(filename); + } + }, + ItemResult::Error(e) => errs.push(format!("{}: {}", &items[a].filename, e)) + } + } + + if !errs.is_empty() { + println!(); + println!("ERRORS OCCURRED:"); + for err in &errs { + println!("{}", &err); + } + } + + if !valid_filenames.is_empty() { + print_tree(&args.output_dir, &valid_filenames); + } + + println!(); + if args.dry_run { + println!("DRY-RUN!"); + } + println!("Converted files: {}", valid_filenames.len()); + println!("Errors occurred: {}", errs.len()); + + if errs.is_empty() { + return Ok(()); + } + + println!(); + return Err("Some errors occurred".into()); + } else { + return Ok(()); + } +} diff --git a/src/meta.rs b/src/meta.rs new file mode 100644 index 0000000..70ba8fb --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use std::process::Command; +use std::collections::HashMap; +use serde_json::Value; +use serde::{Deserialize, Serialize}; +use regex::{Regex}; +use lazy_static::lazy_static; +use std::path::Path; +use std::ffi::OsStr; +use std::error::Error; +use sanitize_filename::{sanitize_with_options, Options}; +use crate::cue::CueInfo; +use std::cmp::Ordering; + +#[derive(Serialize, Deserialize)] +pub struct MetaStreamTags { + comment: Option +} + +#[derive(Serialize, Deserialize)] +pub struct MetaStream { + codec_type: String, + width: Option, + height: Option, + tags: Option +} + +impl MetaStreamTags { + const COVER: &'static str = "Cover (front)"; +} + +impl MetaStream { + const VIDEO: &'static str = "video"; +} + +#[derive(Serialize, Deserialize)] +pub struct MetaFormat { + tags: Option> +} + +#[derive(Serialize, Deserialize)] +pub struct Meta { + streams: Vec, + format: MetaFormat +} + +#[derive(Serialize, Default, Clone)] +pub struct MetaTags { + pub title: String, + pub album: String, + pub artist: String, + pub catalog_number: String, + pub author: String, + pub comment: String, + pub composer: String, + pub lyricist: String, + pub songwriter: String, + pub date: String, + pub disc: String, + pub discs: String, + pub disc_id: String, + pub track: String, + pub tracks: String, + pub genre: String, + pub label: String, + pub performer: String, + pub publisher: String, + pub year: String, + pub file_name: String, + pub dir_name: String, + pub file_base: String, + pub file_ext: String +} + +#[derive(Default)] +pub struct FileMeta { + pub has_pic: bool, + pub pic_width: u32, + pub pic_height: u32, + pub tags: MetaTags +} + +fn to_str(x: Option<&OsStr>) -> String { + return x.unwrap_or_default().to_str().unwrap().to_string(); +} + +fn first_val(map: &HashMap, keys: &[&str]) -> String { + for key in keys { + if let Some(v) = map.get(*key) { + return v.to_string(); + } + } + return Default::default(); +} + +fn fill_tags(hash: &HashMap, filename: &str, cue: &Option) -> MetaTags { + lazy_static! { + static ref RX: Regex = Regex::new(r"[^a-z]").unwrap(); + } + + let mut tags = HashMap::new(); + + let file_path = Path::new(filename).canonicalize().unwrap(); + let dir_path = file_path.parent().unwrap(); + + let mut keys = hash.keys().into_iter().collect::>(); + keys.sort_by(|a, b| { + let ord = a.to_lowercase().cmp(&b.to_lowercase()); + if ord == Ordering::Equal { + return a.cmp(b); + } + return ord; + }); + + for key in keys { + let tag_key = key.to_lowercase(); + let tag_key = RX.replace_all(&tag_key, "").to_string(); + if let Value::String(val) = &hash[key] { + tags.insert(tag_key, val); + } + } + + let mut meta_tags = MetaTags { + title: first_val(&tags, &["title"]), + album: first_val(&tags, &["album"]), + artist: first_val(&tags, &["albumartist", "artist", "artists"]), + catalog_number: first_val(&tags, &["catalog", "catalognumber"]), + author: first_val(&tags, &["author"]), + comment: first_val(&tags, &["comment"]), + composer: first_val(&tags, &["composer"]), + lyricist: first_val(&tags, &["lyricist"]), + songwriter: first_val(&tags, &["songwriter"]), + date: first_val(&tags, &["date", "originaldate", "originalreleasedate"]), + disc: first_val(&tags, &["disc"]), + discs: first_val(&tags, &["disctotal", "totaldiscs"]), + disc_id: first_val(&tags, &["discid"]), + track: first_val(&tags, &["track"]), + tracks: first_val(&tags, &["tracktotal", "totaltracks"]), + genre: first_val(&tags, &["genre"]), + label: first_val(&tags, &["label"]), + performer: first_val(&tags, &["performer"]), + publisher: first_val(&tags, &["publisher"]), + year: first_val(&tags, &["year"]), + file_name: to_str(file_path.file_name()), + dir_name: to_str(dir_path.file_name()), + file_base: to_str(file_path.file_stem()), + file_ext: to_str(file_path.extension()) + }; + + if let Some(cue) = cue { + if !cue.album.is_empty() { + meta_tags.album = cue.album.clone(); + } + if !cue.title.is_empty() { + meta_tags.title = cue.title.clone(); + } + if !cue.songwriter.is_empty() { + meta_tags.songwriter = cue.songwriter.clone(); + } + if !cue.genre.is_empty() { + meta_tags.genre = cue.genre.clone(); + } + if !cue.performer.is_empty() { + meta_tags.performer = cue.performer.clone(); + } + if !cue.date.is_empty() { + meta_tags.date = cue.date.clone(); + } + if !cue.disc_id.is_empty() { + meta_tags.disc_id = cue.disc_id.clone(); + } + if !cue.track.is_empty() { + meta_tags.track = cue.track.clone(); + } + if !cue.tracks.is_empty() { + meta_tags.tracks = cue.tracks.clone(); + } + } + + return meta_tags; +} + +fn filesafe_str(s: &str) -> String { + return sanitize_with_options(s, Options { + replacement: "", + windows: false, + truncate: true + }); +} + +pub fn sanitize_tags(meta: &MetaTags) -> MetaTags { + return MetaTags { + title: filesafe_str(&meta.title), + album: filesafe_str(&meta.album), + artist: filesafe_str(&meta.artist), + catalog_number: filesafe_str(&meta.catalog_number), + author: filesafe_str(&meta.author), + comment: filesafe_str(&meta.comment), + composer: filesafe_str(&meta.composer), + lyricist: filesafe_str(&meta.lyricist), + songwriter: filesafe_str(&meta.songwriter), + date: filesafe_str(&meta.date), + disc: filesafe_str(&meta.disc), + discs: filesafe_str(&meta.discs), + disc_id: filesafe_str(&meta.disc_id), + track: filesafe_str(&meta.track), + tracks: filesafe_str(&meta.tracks), + genre: filesafe_str(&meta.genre), + label: filesafe_str(&meta.label), + performer: filesafe_str(&meta.performer), + publisher: filesafe_str(&meta.publisher), + year: filesafe_str(&meta.year), + file_name: filesafe_str(&meta.file_name), + dir_name: filesafe_str(&meta.dir_name), + file_base: filesafe_str(&meta.file_base), + file_ext: filesafe_str(&meta.file_ext) + } +} + +pub fn fill_fallback_tags(meta_tags: &MetaTags) -> MetaTags { + let mut meta_tags = meta_tags.clone(); + + if meta_tags.year.is_empty() && !meta_tags.date.is_empty() { + lazy_static! { + static ref RX: Regex = Regex::new(r"^\d{4}").unwrap(); + } + if let Some(m) = RX.find(&meta_tags.date) { + meta_tags.year = m.as_str().into(); + } + } else if !meta_tags.year.is_empty() && meta_tags.date.is_empty() { + meta_tags.date = meta_tags.year.clone(); + } + + if meta_tags.title.is_empty() { + meta_tags.title = meta_tags.file_base.clone(); + } + + if meta_tags.album.is_empty() { + meta_tags.album = meta_tags.dir_name.clone(); + } + + if meta_tags.artist.is_empty() { + if !meta_tags.author.is_empty() { + meta_tags.artist = meta_tags.author.clone(); + } else if !meta_tags.performer.is_empty() { + meta_tags.artist = meta_tags.performer.clone(); + } + } + + if meta_tags.author.is_empty() && !meta_tags.artist.is_empty() { + meta_tags.author = meta_tags.artist.clone(); + } + + if meta_tags.songwriter.is_empty() { + if !meta_tags.composer.is_empty() { + meta_tags.songwriter = meta_tags.composer.clone(); + } else if !meta_tags.lyricist.is_empty() { + meta_tags.songwriter = meta_tags.lyricist.clone(); + } else if !meta_tags.artist.is_empty() { + meta_tags.songwriter = meta_tags.artist.clone(); + } + } + + if meta_tags.composer.is_empty() && !meta_tags.songwriter.is_empty() { + meta_tags.composer = meta_tags.songwriter.clone(); + } + + if meta_tags.lyricist.is_empty() && !meta_tags.songwriter.is_empty() { + meta_tags.lyricist = meta_tags.songwriter.clone(); + } + + return meta_tags; +} + +pub fn extract_meta(filename: &str, cue: &Option, ffprobe_bin: &str) -> Result> { + let out = Command::new(ffprobe_bin) + .args(&[ + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + filename + ]) + .output()?.stdout; + let out = std::str::from_utf8(&out)?; + let meta: Meta = serde_json::from_str(&out)?; + + let format_tags = meta.format.tags.unwrap_or_default(); + let tags = fill_tags(&format_tags, filename, cue); + + let mut fmeta = FileMeta { + tags, + ..Default::default() + }; + + for s in meta.streams { + if s.codec_type == MetaStream::VIDEO { + if let Some(tags) = s.tags { + if let Some(comment) = tags.comment { + if comment == MetaStreamTags::COVER { + if let Some(w) = s.width { + if let Some(h) = s.height { + fmeta.has_pic = true; + fmeta.pic_height = h; + fmeta.pic_width = w; + } + } + } + } + } + } + } + + return Ok(fmeta); +} diff --git a/src/pics.rs b/src/pics.rs new file mode 100644 index 0000000..77f553d --- /dev/null +++ b/src/pics.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-only +// 🄯 2021, Alexey Parfenov + +use crate::concurrent_map::ConcurrentMap; +use std::process::Command; +use crate::args::AppArgs; +use crate::Progs; + +pub type PicsMap = ConcurrentMap>>; + +pub fn ffmpeg_conv_pic_args(app_args: &AppArgs) -> Vec { + let fmt = format!( + "scale='w=min({},iw)':h='min({},ih)':force_original_aspect_ratio=decrease:flags=lanczos", + app_args.max_pic_width, app_args.max_pic_height); + let q = app_args.pic_quality.to_string(); + return vec!["-vf".to_string(), fmt, "-qmin".to_string(), "1".to_string(), "-q:v".to_string(), q]; +} + +fn conv_pic(pic_file: &str, app_args: &AppArgs, progs: &Progs) -> Option> { + let pic_args = ffmpeg_conv_pic_args(app_args); + let pic_args = pic_args.iter().map(String::as_str).collect::>(); + + let mut args = vec![ + "-i", pic_file, + "-f", "mjpeg" + ]; + + args.extend(pic_args); + args.push("-"); + + let args_str = shell_words::join(&args); + println!("PIC {}: {} {}", pic_file, &progs.ffmpeg_bin, args_str); + + if app_args.dry_run { + return Some(Vec::new()); + } + + let output = Command::new(&progs.ffmpeg_bin).args(args).output().ok()?; + if output.status.code()? != 0 { + println!("PIC {}: {}", pic_file, std::str::from_utf8(&output.stderr).unwrap()); + return None; + } + return Some(output.stdout); +} + +impl PicsMap { + pub fn conv_pic_if_needed(&self, pic_filename: &str, args: &AppArgs, progs: &Progs) -> Option> { + if let Some(Some(p)) = self.set_if_not_exists( + &pic_filename.into(), + || conv_pic(pic_filename, args, progs) + ) { + return Some(p); + } + return None; + } +} + +pub fn find_cover_in_dir(dir_name: &str, cover_names: &[String], cover_exts: &[String]) -> Option { + let entries = std::fs::read_dir(dir_name).ok()?; + for entry in entries { + let entry = entry.ok()?; + if !entry.file_type().ok()?.is_file() { + continue; + } + + let ext = entry.path().extension().unwrap_or_default().to_str()?.to_lowercase(); + let basename = entry.path().file_stem()?.to_str()?.to_lowercase(); + + for cover_basename in cover_names.iter() { + if basename == *cover_basename { + for cover_ext in cover_exts.iter() { + if ext == *cover_ext { + let canonical_path = entry.path().canonicalize().ok()?; + let file_path = canonical_path.to_str()?; + return Some(file_path.into()); + } + } + } + } + } + + return None; +}