diff --git a/.gitignore b/.gitignore index 6e69e0d..eb63ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ dist-ssr # Rust Stuff target -Cargo.lock \ No newline at end of file +Cargo.lock +.cargo diff --git a/dmp-core/.gitignore b/dmp-core/.gitignore new file mode 100644 index 0000000..0714bdf --- /dev/null +++ b/dmp-core/.gitignore @@ -0,0 +1,18 @@ +# Rust binary output dir +target/ +test-config/ +# Rust configuration +Cargo.lock +.cargo/ + +# Database files +*.db3* +music_database* + +# Storage formats +*.kate-swp* +*.m3u +*.m3u8 +*.json +*.zip +*.xml \ No newline at end of file diff --git a/dmp-core/Cargo.toml b/dmp-core/Cargo.toml new file mode 100644 index 0000000..8945da1 --- /dev/null +++ b/dmp-core/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "dmp-core" +version = "0.0.0" +edition = "2021" +license = "AGPL-3.0-only" +description = "Backend crate for the Dango Music Player " +homepage = "" +documentation = "" +readme = "README.md" +repository = "https://github.com/Dangoware/dmp-core" +keywords = [] +categories = [] + +[dependencies] +file-format = { version = "0.23.0", features = [ + "reader-asf", + "reader-ebml", + "reader-mp4", + "reader-rm", + "reader-txt", + "reader-xml", + "serde", +] } +lofty = "0.18.2" +serde = { version = "1.0.195", features = ["derive"] } +walkdir = "2.4.0" +chrono = { version = "0.4.31", features = ["serde"] } +rayon = "1.8.0" +log = "0.4" +base64 = "0.21.5" +rcue = "0.1.3" +gstreamer = "0.21.3" +glib = "0.18.5" +crossbeam-channel = "0.5.8" +crossbeam = "0.8.2" +quick-xml = "0.31.0" +leb128 = "0.2.5" +urlencoding = "2.1.3" +m3u8-rs = "5.0.5" +thiserror = "1.0.56" +uuid = { version = "1.6.1", features = ["v4", "serde"] } +serde_json = "1.0.111" +deunicode = "1.4.2" +opener = { version = "0.7.0", features = ["reveal"] } +tempfile = "3.10.1" +listenbrainz = "0.7.0" +discord-rpc-client = "0.4.0" +nestify = "0.3.3" +kushi = "0.1.3" +moro = "0.4.0" +moro-local = "0.4.0" +futures = "0.3.30" +text_io = "0.1.12" +tokio = { version = "1.40.0", features = ["macros", "rt"] } +async-channel = "2.3.1" +ciborium = "0.2.2" diff --git a/dmp-core/LICENSE b/dmp-core/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/dmp-core/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/dmp-core/README.md b/dmp-core/README.md new file mode 100644 index 0000000..751669c --- /dev/null +++ b/dmp-core/README.md @@ -0,0 +1,3 @@ +# dango-core + +This is the backend crate for the [Dango Music Player](https://github.com/Dangoware/dango-music-player) diff --git a/dmp-core/src/bus_control.rs b/dmp-core/src/bus_control.rs new file mode 100644 index 0000000..e69de29 diff --git a/dmp-core/src/config/mod.rs b/dmp-core/src/config/mod.rs new file mode 100644 index 0000000..85bfffe --- /dev/null +++ b/dmp-core/src/config/mod.rs @@ -0,0 +1,252 @@ +pub mod other_settings; + +use std::{ + fs::{self, File, OpenOptions}, + io::{Error, Read, Write}, + path::PathBuf, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::to_string_pretty; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigLibrary { + pub name: String, + pub path: PathBuf, + pub uuid: Uuid, + pub scan_folders: Option>, +} + +impl Default for ConfigLibrary { + fn default() -> Self { + ConfigLibrary { + name: String::new(), + path: PathBuf::from("library"), + uuid: Uuid::new_v4(), + scan_folders: None, + } + } +} + +impl ConfigLibrary { + pub fn new(path: PathBuf, name: String, scan_folders: Option>) -> Self { + ConfigLibrary { + name, + path, + uuid: Uuid::new_v4(), + scan_folders, + } + } + + pub fn open(&self) -> Result { + match File::open(self.path.as_path()) { + Ok(ok) => Ok(ok), + Err(e) => Err(e), + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct ConfigLibraries { + pub default_library: Uuid, + pub library_folder: PathBuf, + pub libraries: Vec, +} + +impl ConfigLibraries { + pub fn set_default(mut self, uuid: &Uuid) { + self.default_library = *uuid; + } + + pub fn get_default(&self) -> Result<&ConfigLibrary, ConfigError> { + for library in &self.libraries { + if library.uuid == self.default_library { + return Ok(library); + } + } + Err(ConfigError::NoDefaultLibrary) + } + + pub fn get_library(&self, uuid: &Uuid) -> Result { + for library in &self.libraries { + // dbg!(&library.uuid, &uuid); + if &library.uuid == uuid { + return Ok(library.to_owned()); + } + } + Err(ConfigError::NoConfigLibrary(*uuid)) + } + + pub fn uuid_exists(&self, uuid: &Uuid) -> bool { + for library in &self.libraries { + if &library.uuid == uuid { + return true; + } + } + false + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct ConfigConnections { + pub listenbrainz_token: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(default)] +pub struct Config { + pub path: PathBuf, + pub backup_folder: Option, + pub libraries: ConfigLibraries, + pub volume: f32, + pub connections: ConfigConnections, +} + +impl Config { + pub fn new() -> Self { + Config { + libraries: ConfigLibraries { + libraries: vec![ConfigLibrary::default()], + ..Default::default() + }, + ..Default::default() + } + } + + pub fn new_main() -> Self { + Config::default() + } + + pub fn write_file(&self) -> Result<(), Error> { + let mut writer = self.path.clone(); + writer.set_extension("tmp"); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&writer)?; + let config = to_string_pretty(self)?; + // dbg!(&config); + + file.write_all(config.as_bytes())?; + fs::rename(writer, self.path.as_path())?; + Ok(()) + } + + pub fn save_backup(&self) -> Result<(), Box> { + match &self.backup_folder { + Some(path) => { + let mut writer = path.clone(); + writer.set_extension("tmp"); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&writer)?; + let config = to_string_pretty(self)?; + // dbg!(&config); + + file.write_all(config.as_bytes())?; + fs::rename(writer, self.path.as_path())?; + Ok(()) + } + None => Err(ConfigError::NoBackupLibrary.into()), + } + } + + pub fn read_file(path: PathBuf) -> Result { + let mut file: File = File::open(path)?; + let mut bun: String = String::new(); + _ = file.read_to_string(&mut bun); + let config: Config = serde_json::from_str::(&bun)?; + Ok(config) + } + + pub fn push_library(&mut self, lib: ConfigLibrary) { + if self.libraries.libraries.is_empty() { + self.libraries.default_library = lib.uuid; + } + self.libraries.libraries.push(lib); + } +} + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("No Library Found for {0}!")] + NoConfigLibrary(Uuid), + #[error("There is no Default Library for this Config")] + NoDefaultLibrary, + //TODO: do something about playlists + #[error("Please provide a better m3u8 Playlist")] + BadPlaylist, + #[error("No backup Config folder present")] + NoBackupLibrary, +} + +#[cfg(test)] +pub mod tests { + use super::{Config, ConfigLibrary}; + use crate::music_storage::library::MusicLibrary; + use std::{ + path::PathBuf, + }; + + pub fn new_config_lib() -> (Config, MusicLibrary) { + _ = std::fs::create_dir_all("test-config/music/"); + let lib = ConfigLibrary::new( + PathBuf::from("test-config/library"), + String::from("library"), + None, + ); + let mut config = Config { + path: PathBuf::from("test-config/config_test.json"), + ..Default::default() + }; + + config.push_library(lib); + config.write_file().unwrap(); + + let mut lib = MusicLibrary::init( + config.libraries.get_default().unwrap().path.clone(), + dbg!(config.libraries.default_library), + ) + .unwrap(); + lib.scan_folder("test-config/music/").unwrap(); + lib.save(config.libraries.get_default().unwrap().path.clone()) + .unwrap(); + + (config, lib) + } + + pub fn read_config_lib() -> (Config, MusicLibrary) { + let config = Config::read_file(PathBuf::from("test-config/config_test.json")).unwrap(); + + // dbg!(&config); + + let mut lib = MusicLibrary::init( + config.libraries.get_default().unwrap().path.clone(), + config.libraries.get_default().unwrap().uuid, + ) + .unwrap(); + + lib.scan_folder("test-config/music/").unwrap(); + + lib.save(config.libraries.get_default().unwrap().path.clone()) + .unwrap(); + + (config, lib) + } + + #[test] + fn test3() { + let (config, _) = read_config_lib(); + + _ = config.write_file(); + + dbg!(config); + } +} diff --git a/dmp-core/src/config/other_settings.rs b/dmp-core/src/config/other_settings.rs new file mode 100644 index 0000000..1b80e94 --- /dev/null +++ b/dmp-core/src/config/other_settings.rs @@ -0,0 +1,7 @@ +pub enum Setting { + String { name: String, value: String }, + Int { name: String, value: i32 }, + Bool { name: String, value: bool }, +} + +pub struct Form {} diff --git a/dmp-core/src/lib.rs b/dmp-core/src/lib.rs new file mode 100644 index 0000000..0f41571 --- /dev/null +++ b/dmp-core/src/lib.rs @@ -0,0 +1,22 @@ +pub mod music_storage { + pub mod library; + pub mod music_collection; + pub mod playlist; + mod utils; + + #[allow(dead_code)] + pub mod db_reader; +} + +pub mod music_controller { + pub mod connections; + pub mod controller; + pub mod queue; +} + +pub mod music_player { + pub mod gstreamer; + pub mod player; +} + +pub mod config; diff --git a/dmp-core/src/music_controller/connections.rs b/dmp-core/src/music_controller/connections.rs new file mode 100644 index 0000000..5ea823e --- /dev/null +++ b/dmp-core/src/music_controller/connections.rs @@ -0,0 +1,100 @@ +// use std::{ +// sync::{Arc, RwLock}, +// error::Error, +// }; + +// use discord_rpc_client::Client; +// use listenbrainz::ListenBrainz; +// use uuid::Uuid; + +// use crate::{ +// config::config::Config, music_controller::controller::{Controller, QueueCmd, QueueResponse}, music_storage::library::{MusicLibrary, Song, Tag} +// }; + +// use super::controller::DatabaseResponse; + +// impl Controller { +// pub fn listenbrainz_authenticate(&mut self) -> Result> { +// let config = &self.config.read().unwrap(); +// let mut client = ListenBrainz::new(); + +// let lbz_token = match &config.connections.listenbrainz_token { +// Some(token) => token, +// None => todo!("No ListenBrainz token in config") +// }; + +// if !client.is_authenticated() { +// client.authenticate(lbz_token)?; +// } + +// Ok(client) +// } +// pub fn lbz_scrobble(&self, client: ListenBrainz, uuid: Uuid) -> Result<(), Box> { +// let config = &self.config.read().unwrap(); + +// &self.db_mail.send(super::controller::DatabaseCmd::QueryUuid(uuid)); +// let res = &self.db_mail.recv()?; +// let song = match res { +// DatabaseResponse::Song(song) => song, +// _ => todo!() +// }; +// let unknown = &"unknown".to_string(); +// let artist = song.get_tag(&Tag::Artist).unwrap_or(unknown); +// let track = song.get_tag(&Tag::Title).unwrap_or(unknown); +// let release = song.get_tag(&Tag::Album).map(|rel| rel.as_str()); + +// client.listen(artist, track, release)?; +// Ok(()) +// } + +// pub fn lbz_now_playing(&self, client: ListenBrainz, uuid: Uuid) -> Result<(), Box> { +// let config = &self.config.read().unwrap(); + +// &self.db_mail.send(super::controller::DatabaseCmd::QueryUuid(uuid)); +// let res = &self.db_mail.recv()?; +// let song = match res { +// DatabaseResponse::Song(song) => song, +// _ => todo!() +// }; +// let unknown = &"unknown".to_string(); +// let artist = song.get_tag(&Tag::Artist).unwrap_or(unknown); +// let track = song.get_tag(&Tag::Title).unwrap_or(unknown); +// let release = song.get_tag(&Tag::Album).map(|rel| rel.as_str()); + +// client.listen(artist, track, release)?; +// Ok(()) +// } + +// pub fn discord_song_change(client: &mut Client,song: Song) { +// client.set_activity(|a| { +// a.state(format!("Listening to {}", song.get_tag(&Tag::Title).unwrap())) +// .into() +// }); +// } +// } + +// #[cfg(test)] +// mod test_super { +// use std::{thread::sleep, time::Duration}; + +// use super::*; +// use crate::config::config::tests::read_config_lib; + +// #[test] +// fn listenbrainz() { +// let mut c = Controller::start(".\\test-config\\config_test.json").unwrap(); + +// let client = c.listenbrainz_authenticate().unwrap(); + +// c.q_new().unwrap(); +// c.queue_mail[0].send(QueueCmd::SetVolume(0.04)).unwrap(); + +// let songs = c.lib_get_songs(); + +// c.q_enqueue(0, songs[1].location.to_owned()).unwrap(); +// c.q_play(0).unwrap(); + +// sleep(Duration::from_secs(100)); +// c.lbz_scrobble(client, songs[1].uuid).unwrap(); +// } +// } diff --git a/dmp-core/src/music_controller/controller.rs b/dmp-core/src/music_controller/controller.rs new file mode 100644 index 0000000..950d7d5 --- /dev/null +++ b/dmp-core/src/music_controller/controller.rs @@ -0,0 +1,499 @@ +//! The [Controller] is the input and output for the entire +//! player. It manages queues, playback, library access, and +//! other functions +#![allow(while_true)] + +use kushi::{Queue, QueueItemType}; +use kushi::{QueueError, QueueItem}; +use std::error::Error; +use std::marker::PhantomData; +use std::sync::{Arc, RwLock}; +use thiserror::Error; +use uuid::Uuid; + +use crate::config::ConfigError; +use crate::music_player::player::{Player, PlayerError}; +use crate::music_storage::library::Song; +use crate::{config::Config, music_storage::library::MusicLibrary}; + +use super::queue::{QueueAlbum, QueueSong}; + +pub struct Controller<'a, P>(&'a PhantomData

); + +#[derive(Error, Debug)] +pub enum ControllerError { + #[error("{0:?}")] + QueueError(#[from] QueueError), + #[error("{0:?}")] + PlayerError(#[from] PlayerError), + #[error("{0:?}")] + ConfigError(#[from] ConfigError), +} + +// TODO: move this to a different location to be used elsewhere +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum PlayerLocation { + Test, + Library, + Playlist(Uuid), + File, + Custom, +} + +#[derive(Debug, Clone)] +pub struct MailMan { + tx: async_channel::Sender, + rx: async_channel::Receiver, +} + +impl MailMan { + pub fn double() -> (MailMan, MailMan) { + let (tx, rx) = async_channel::unbounded::(); + let (tx1, rx1) = async_channel::unbounded::(); + + (MailMan { tx, rx: rx1 }, MailMan { tx: tx1, rx }) + } + + pub async fn send(&self, mail: Tx) -> Result<(), async_channel::SendError> { + self.tx.send(mail).await + } + + pub async fn recv(&self) -> Result { + self.rx.recv().await + } +} + +#[derive(Debug, PartialEq, PartialOrd, Clone)] +pub enum PlayerCommand { + NextSong, + PrevSong, + Pause, + Play, + Enqueue(usize), + SetVolume(f64), +} + +#[derive(Debug, PartialEq, PartialOrd, Clone)] +pub enum PlayerResponse { + Empty, +} + +pub enum LibraryCommand { + Song(Uuid), + AllSongs, + GetLibrary, +} + +pub enum LibraryResponse { + Song(Song), + AllSongs(Vec), + Library(MusicLibrary), +} + +enum InnerLibraryCommand { + Song(Uuid), + AllSongs, +} + +enum InnerLibraryResponse<'a> { + Song(&'a Song), + AllSongs(&'a Vec), +} + +pub enum QueueCommand { + Append(QueueItem), + Next, + Prev, + GetIndex(usize), + NowPlaying, +} + +pub enum QueueResponse { + Ok, + Item(QueueItem), +} + + +pub struct ControllerInput { + player_mail: ( + MailMan, + MailMan, + ), + lib_mail: MailMan, + library: MusicLibrary, + config: Arc>, +} + +pub struct ControllerHandle { + pub lib_mail: MailMan, + pub player_mail: MailMan, +} + +impl ControllerHandle { + pub fn new(library: MusicLibrary, config: Arc>) -> (Self, ControllerInput) { + let lib_mail = MailMan::double(); + let player_mail = MailMan::double(); + + ( + ControllerHandle { + lib_mail: lib_mail.0, + player_mail: player_mail.0.clone() + }, + ControllerInput { + player_mail, + lib_mail: lib_mail.1, + library, + config + } + ) + } +} + +#[allow(unused_variables)] +impl<'c, P: Player + Send + Sync> Controller<'c, P> { + pub async fn start( + ControllerInput { + player_mail, + lib_mail, + mut library, + config + }: ControllerInput + ) -> Result<(), Box> + where + P: Player, + { + //TODO: make a separate event loop for sccessing library that clones borrowed values from inner library loop? + let mut queue: Queue = Queue { + items: Vec::new(), + played: Vec::new(), + loop_: false, + shuffle: None, + }; + + for song in &library.library { + queue.add_item( + QueueSong { + song: song.clone(), + location: PlayerLocation::Test, + }, + true, + ); + } + let inner_lib_mail = MailMan::double(); + let queue = queue; + + std::thread::scope(|scope| { + let queue_mail = MailMan::double(); + let a = scope.spawn(|| { + futures::executor::block_on(async { + moro::async_scope!(|scope| { + println!("async scope created"); + let player = Arc::new(RwLock::new(P::new().unwrap())); + + let _player = player.clone(); + scope + .spawn(async move { + Controller::

::player_command_loop( + _player, + player_mail.1, + queue_mail.0, + ) + .await + .unwrap(); + }); + scope + .spawn(async move { + Controller::

::player_event_loop(player, player_mail.0) + .await + .unwrap(); + }); + scope + .spawn(async { + Controller::

::inner_library_loop(inner_lib_mail.1, &mut library).await + .unwrap() + }); + scope + .spawn(async { + Controller::

::outer_library_loop(lib_mail, inner_lib_mail.0) + .await + .unwrap(); + }); + }) + .await; + }) + }); + + let b = scope.spawn(|| { + futures::executor::block_on(async { + Controller::

::queue_loop(queue, queue_mail.1).await; + }) + }); + a.join().unwrap(); + b.join().unwrap(); + }); + + Ok(()) + } + + async fn player_command_loop( + player: Arc>, + player_mail: MailMan, + queue_mail: MailMan, + ) -> Result<(), ()> { + { + player.write().unwrap().set_volume(0.05); + } + while true { + let _mail = player_mail.recv().await; + if let Ok(mail) = _mail { + match mail { + PlayerCommand::Play => { + player.write().unwrap().play().unwrap(); + player_mail.send(PlayerResponse::Empty).await.unwrap(); + } + PlayerCommand::Pause => { + player.write().unwrap().pause().unwrap(); + player_mail.send(PlayerResponse::Empty).await.unwrap(); + } + PlayerCommand::SetVolume(volume) => { + player.write().unwrap().set_volume(volume); + println!("volume set to {volume}"); + player_mail.send(PlayerResponse::Empty).await.unwrap(); + } + PlayerCommand::NextSong => { + queue_mail.send(QueueCommand::Next).await.unwrap(); + + if let QueueResponse::Item(item) = queue_mail.recv().await.unwrap() { + let uri = match &item.item { + QueueItemType::Single(song) => song.song.primary_uri().unwrap().0, + _ => unimplemented!(), + }; + player.write().unwrap().enqueue_next(uri).unwrap(); + player_mail.send(PlayerResponse::Empty).await.unwrap(); + } + } + PlayerCommand::PrevSong => { + queue_mail.send(QueueCommand::Prev).await.unwrap(); + + if let QueueResponse::Item(item) = queue_mail.recv().await.unwrap() { + let uri = match &item.item { + QueueItemType::Single(song) => song.song.primary_uri().unwrap().0, + _ => unimplemented!(), + }; + player.write().unwrap().enqueue_next(uri).unwrap(); + player_mail.send(PlayerResponse::Empty).await.unwrap(); + } + } + PlayerCommand::Enqueue(index) => { + queue_mail + .send(QueueCommand::GetIndex(index)) + .await + .unwrap(); + if let QueueResponse::Item(item) = queue_mail.recv().await.unwrap() { + match item.item { + QueueItemType::Single(song) => { + player + .write() + .unwrap() + .enqueue_next(song.song.primary_uri().unwrap().0) + .unwrap(); + } + _ => unimplemented!(), + } + player_mail.send(PlayerResponse::Empty).await.unwrap(); + } + } + } + } else { + return Err(()); + } + } + Ok(()) + } + + async fn outer_library_loop( + lib_mail: MailMan, + inner_lib_mail: MailMan>, + ) -> Result<(), ()> { + println!("outer lib loop"); + while true { + match lib_mail.recv().await.unwrap() { + LibraryCommand::Song(uuid) => { + println!("got song commandf"); + inner_lib_mail + .send(InnerLibraryCommand::Song(uuid)) + .await + .unwrap(); + let x = inner_lib_mail.recv().await.unwrap(); + } + LibraryCommand::AllSongs => { + println!("got command"); + inner_lib_mail + .send(InnerLibraryCommand::AllSongs) + .await + .unwrap(); + println!("sent"); + let x = inner_lib_mail.recv().await.unwrap(); + println!("recieved"); + if let InnerLibraryResponse::AllSongs(songs) = x { + lib_mail.send(LibraryResponse::AllSongs(songs.clone())).await.unwrap(); + } else { + unreachable!() + } + }, + _ => { todo!() } + } + } + Ok(()) + } + + async fn inner_library_loop( + lib_mail: MailMan, InnerLibraryCommand>, + library: &'c mut MusicLibrary, + ) -> Result<(), ()> { + while true { + match lib_mail.recv().await.unwrap() { + InnerLibraryCommand::Song(uuid) => { + let song: &'c Song = library.query_uuid(&uuid).unwrap().0; + lib_mail + .send(InnerLibraryResponse::Song(song)) + .await + .unwrap(); + } + InnerLibraryCommand::AllSongs => { + let songs: &'c Vec = &library.library; + lib_mail.send(InnerLibraryResponse::AllSongs(songs)) + .await + .unwrap(); + } + } + } + Ok(()) + } + + async fn player_event_loop( + player: Arc>, + player_mail: MailMan, + ) -> Result<(), ()> { + // just pretend this does something + Ok(()) + } + + async fn queue_loop( + mut queue: Queue, + queue_mail: MailMan, + ) { + while true { + match queue_mail.recv().await.unwrap() { + QueueCommand::Append(item) => match item.item { + QueueItemType::Single(song) => queue.add_item(song, true), + _ => unimplemented!(), + }, + QueueCommand::Next => { + let next = queue.next().unwrap(); + queue_mail + .send(QueueResponse::Item(next.clone())) + .await + .unwrap(); + } + QueueCommand::Prev => { + let next = queue.prev().unwrap(); + queue_mail + .send(QueueResponse::Item(next.clone())) + .await + .unwrap(); + } + QueueCommand::GetIndex(index) => { + let item = queue.items[index].clone(); + queue_mail.send(QueueResponse::Item(item)).await.unwrap(); + } + QueueCommand::NowPlaying => { + let item = queue.current().unwrap(); + queue_mail + .send(QueueResponse::Item(item.clone())) + .await + .unwrap(); + } + } + } + } +} + +#[cfg(test)] +mod test_super { + use std::{ + path::PathBuf, + sync::{Arc, RwLock}, + thread::spawn, + }; + + use crate::{ + config::{tests::new_config_lib, Config}, + music_controller::controller::{ + LibraryCommand, LibraryResponse, MailMan, PlayerCommand, PlayerResponse, ControllerHandle + }, + music_player::gstreamer::GStreamer, + music_storage::library::MusicLibrary, + }; + + use super::Controller; + + #[tokio::test] + async fn construct_controller() { + // use if you don't have a config setup and add music to the music folder + new_config_lib(); + + let config = Config::read_file(PathBuf::from(std::env!("CONFIG-PATH"))).unwrap(); + let mut library = { + MusicLibrary::init( + config.libraries.get_default().unwrap().path.clone(), + config.libraries.get_default().unwrap().uuid, + ) + .unwrap() + }; + + let (handle, input) = ControllerHandle::new(library, Arc::new(RwLock::new(config))); + + let b = spawn(move || { + futures::executor::block_on(async { + handle.player_mail + .send(PlayerCommand::SetVolume(0.01)) + .await + .unwrap(); + loop { + let buf: String = text_io::read!(); + dbg!(&buf); + handle.player_mail + .send(match buf.to_lowercase().as_str() { + "next" => PlayerCommand::NextSong, + "prev" => PlayerCommand::PrevSong, + "pause" => PlayerCommand::Pause, + "play" => PlayerCommand::Play, + x if x.parse::().is_ok() => { + PlayerCommand::Enqueue(x.parse::().unwrap()) + } + _ => continue, + }) + .await + .unwrap(); + println!("sent it"); + println!("{:?}", handle.player_mail.recv().await.unwrap()) + } + }) + }); + + let a = spawn(move || { + futures::executor::block_on(async { + + + Controller::::start(input) + .await + .unwrap(); + }); + }); + + b.join().unwrap(); + a.join().unwrap(); + } +} diff --git a/dmp-core/src/music_controller/queue.rs b/dmp-core/src/music_controller/queue.rs new file mode 100644 index 0000000..27f8075 --- /dev/null +++ b/dmp-core/src/music_controller/queue.rs @@ -0,0 +1,25 @@ +use std::vec::IntoIter; + +use crate::music_storage::library::{Album, AlbumTrack, Song}; + +use super::controller::PlayerLocation; + +#[derive(Debug, Clone, PartialEq)] +pub struct QueueSong { + pub song: Song, + pub location: PlayerLocation, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct QueueAlbum { + pub album: Album, + pub location: PlayerLocation, +} + +impl IntoIterator for QueueAlbum { + type Item = AlbumTrack; + type IntoIter = IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.album.into_iter() + } +} diff --git a/dmp-core/src/music_player/gstreamer.rs b/dmp-core/src/music_player/gstreamer.rs new file mode 100644 index 0000000..7733d85 --- /dev/null +++ b/dmp-core/src/music_player/gstreamer.rs @@ -0,0 +1,521 @@ +// Crate things +use crate::music_storage::library::URI; +use crossbeam_channel::{unbounded, Receiver, Sender}; +use std::error::Error; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +// GStreamer things +use glib::FlagsClass; +use gst::{ClockTime, Element}; +use gstreamer as gst; +use gstreamer::prelude::*; + +// Extra things +use chrono::Duration; + +use super::player::{Player, PlayerCommand, PlayerError, PlayerState}; + +impl From for PlayerState { + fn from(value: gst::State) -> Self { + match value { + gst::State::VoidPending => Self::VoidPending, + gst::State::Playing => Self::Playing, + gst::State::Paused => Self::Paused, + gst::State::Ready => Self::Ready, + gst::State::Null => Self::Null, + } + } +} + +impl TryInto for PlayerState { + fn try_into(self) -> Result> { + match self { + Self::VoidPending => Ok(gst::State::VoidPending), + Self::Playing => Ok(gst::State::Playing), + Self::Paused => Ok(gst::State::Paused), + Self::Ready => Ok(gst::State::Ready), + Self::Null => Ok(gst::State::Null), + state => Err(format!("Invalid gst::State: {:?}", state).into()), + } + } + + type Error = Box; +} + +#[derive(Debug, PartialEq, Eq)] +enum PlaybackInfo { + Idle, + Switching, + Playing { + start: Duration, + end: Duration, + }, + + /// When this is sent, the thread will die! Use it when the [Player] is + /// done playing + Finished, +} + +/// An instance of a music player with a GStreamer backend +#[derive(Debug)] +pub struct GStreamer { + source: Option, + + message_rx: crossbeam::channel::Receiver, + playback_tx: crossbeam::channel::Sender, + + playbin: Arc>, + volume: f64, + start: Option, + end: Option, + paused: Arc>, + position: Arc>>, +} + +impl From for PlayerError { + fn from(value: gst::StateChangeError) -> Self { + PlayerError::StateChange(value.to_string()) + } +} + +impl From for PlayerError { + fn from(value: glib::BoolError) -> Self { + PlayerError::General(value.to_string()) + } +} + +impl GStreamer { + /// Set the playback URI + fn set_source(&mut self, source: &URI) -> Result<(), PlayerError> { + if !source.exists().is_ok_and(|x| x) { + // If the source doesn't exist, gstreamer will crash! + return Err(PlayerError::NotFound); + } + + // Make sure the playback tracker knows the stuff is stopped + println!("Beginning switch"); + self.playback_tx.send(PlaybackInfo::Switching).unwrap(); + + let uri = self.playbin.read().unwrap().property_value("current-uri"); + self.source = Some(source.clone()); + match source { + URI::Cue { start, end, .. } => { + self.playbin + .write() + .unwrap() + .set_property("uri", source.as_uri()); + + // Set the start and end positions of the CUE file + self.start = Some(Duration::from_std(*start).unwrap()); + self.end = Some(Duration::from_std(*end).unwrap()); + + // Send the updated position to the tracker + self.playback_tx + .send(PlaybackInfo::Playing { + start: self.start.unwrap(), + end: self.end.unwrap(), + }) + .unwrap(); + + // Wait for it to be ready, and then move to the proper position + self.play().unwrap(); + let now = std::time::Instant::now(); + while now.elapsed() < std::time::Duration::from_millis(20) { + if self.seek_to(Duration::from_std(*start).unwrap()).is_ok() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + //panic!("Couldn't seek to beginning of cue track in reasonable time (>20ms)"); + return Err(PlayerError::StateChange( + "Could not seek to beginning of CUE track".into(), + )); + } + _ => { + self.playbin + .write() + .unwrap() + .set_property("uri", source.as_uri()); + + if self.state() != PlayerState::Playing { + self.play().unwrap(); + } + + while self.raw_duration().is_none() { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + self.start = Some(Duration::seconds(0)); + self.end = self.raw_duration(); + + // Send the updated position to the tracker + self.playback_tx + .send(PlaybackInfo::Playing { + start: self.start.unwrap(), + end: self.end.unwrap(), + }) + .unwrap(); + } + } + + Ok(()) + } + + /// Gets a mutable reference to the playbin element + fn playbin_mut( + &mut self, + ) -> Result, std::sync::PoisonError>> + { + let element = match self.playbin.write() { + Ok(element) => element, + Err(err) => return Err(err), + }; + Ok(element) + } + + /// Gets a read-only reference to the playbin element + fn playbin( + &self, + ) -> Result, std::sync::PoisonError>> + { + let element = match self.playbin.read() { + Ok(element) => element, + Err(err) => return Err(err), + }; + Ok(element) + } + + /// Set volume of the internal playbin player, can be + /// used to bypass the main volume control for seeking + fn set_gstreamer_volume(&mut self, volume: f64) { + self.playbin_mut().unwrap().set_property("volume", volume) + } + + fn set_state(&mut self, state: gst::State) -> Result<(), gst::StateChangeError> { + self.playbin_mut().unwrap().set_state(state)?; + + Ok(()) + } + + fn raw_duration(&self) -> Option { + self.playbin() + .unwrap() + .query_duration::() + .map(|pos| Duration::nanoseconds(pos.nseconds() as i64)) + } + + /// Get the current state of the playback + fn state(&mut self) -> PlayerState { + self.playbin().unwrap().current_state().into() + /* + match *self.buffer.read().unwrap() { + None => self.playbin().unwrap().current_state().into(), + Some(value) => PlayerState::Buffering(value), + } + */ + } + + fn property(&self, property: &str) -> glib::Value { + self.playbin().unwrap().property_value(property) + } + + fn ready(&mut self) -> Result<(), PlayerError> { + self.set_state(gst::State::Ready)?; + Ok(()) + } +} + +impl Player for GStreamer { + fn new() -> Result { + // Initialize GStreamer, maybe figure out how to nicely fail here + if let Err(err) = gst::init() { + return Err(PlayerError::Init(err.to_string())); + }; + let ctx = glib::MainContext::default(); + let _guard = ctx.acquire(); + let mainloop = glib::MainLoop::new(Some(&ctx), false); + + let playbin_arc = Arc::new(RwLock::new( + match gst::ElementFactory::make("playbin3").build() { + Ok(playbin) => playbin, + Err(error) => return Err(PlayerError::Init(error.to_string())), + }, + )); + + let playbin = playbin_arc.clone(); + + let flags = playbin.read().unwrap().property_value("flags"); + let flags_class = FlagsClass::with_type(flags.type_()).unwrap(); + + // Set up the Playbin flags to only play audio + let flags = flags_class + .builder_with_value(flags) + .ok_or(PlayerError::Build)? + .set_by_nick("audio") + .set_by_nick("download") + .unset_by_nick("video") + .unset_by_nick("text") + .build() + .ok_or(PlayerError::Build)?; + + playbin + .write() + .unwrap() + .set_property_from_value("flags", &flags); + //playbin.write().unwrap().set_property("instant-uri", true); + + let position = Arc::new(RwLock::new(None)); + + // Set up the thread to monitor the position + let (playback_tx, playback_rx) = unbounded(); + let (status_tx, status_rx) = unbounded::(); + let position_update = Arc::clone(&position); + + std::thread::spawn(|| { + playback_monitor(playbin_arc, status_rx, playback_tx, position_update) + }); + + // Set up the thread to monitor bus messages + let playbin_bus_ctrl = Arc::clone(&playbin); + let paused = Arc::new(RwLock::new(false)); + let bus_paused = Arc::clone(&paused); + let bus_watch = playbin + .read() + .unwrap() + .bus() + .expect("Failed to get GStreamer message bus") + .add_watch(move |_bus, msg| { + match msg.view() { + gst::MessageView::Eos(_) => println!("End of stream"), + gst::MessageView::StreamStart(_) => println!("Stream start"), + gst::MessageView::Error(err) => { + println!("Error recieved: {}", err); + return glib::ControlFlow::Break; + } + gst::MessageView::Buffering(buffering) => { + if *bus_paused.read().unwrap() == true { + return glib::ControlFlow::Continue; + } + + // If the player is not paused, pause it + let percent = buffering.percent(); + if percent < 100 { + playbin_bus_ctrl + .write() + .unwrap() + .set_state(gst::State::Paused) + .unwrap(); + } else if percent >= 100 { + println!("Finished buffering"); + playbin_bus_ctrl + .write() + .unwrap() + .set_state(gst::State::Playing) + .unwrap(); + } + } + _ => (), + } + glib::ControlFlow::Continue + }) + .expect("Failed to connect to GStreamer message bus"); + + // Set up a thread to watch the messages + std::thread::spawn(move || { + let _watch = bus_watch; + mainloop.run() + }); + + let source = None; + Ok(Self { + source, + playbin, + message_rx: playback_rx, + playback_tx: status_tx, + volume: 1.0, + start: None, + end: None, + paused, + position, + }) + } + + fn source(&self) -> &Option { + &self.source + } + + fn enqueue_next(&mut self, next_track: &URI) -> Result<(), PlayerError> { + println!("enqueuing in fn"); + self.set_source(next_track) + } + + fn set_volume(&mut self, volume: f64) { + self.volume = volume.clamp(0.0, 1.0); + self.set_gstreamer_volume(self.volume); + } + + fn volume(&self) -> f64 { + self.volume + } + + fn play(&mut self) -> Result<(), PlayerError> { + if self.state() == PlayerState::Playing { + return Ok(()); + } + *self.paused.write().unwrap() = false; + self.set_state(gst::State::Playing)?; + Ok(()) + } + + fn pause(&mut self) -> Result<(), PlayerError> { + if self.state() == PlayerState::Paused || *self.paused.read().unwrap() { + return Ok(()); + } + *self.paused.write().unwrap() = true; + self.set_state(gst::State::Paused)?; + Ok(()) + } + + fn is_paused(&self) -> bool { + self.playbin().unwrap().current_state() == gst::State::Paused + } + + fn position(&self) -> Option { + *self.position.read().unwrap() + } + + fn duration(&self) -> Option { + if self.end.is_some() && self.start.is_some() { + Some(self.end.unwrap() - self.start.unwrap()) + } else { + self.raw_duration() + } + } + + fn seek_by(&mut self, seek_amount: Duration) -> Result<(), PlayerError> { + let time_pos = match *self.position.read().unwrap() { + Some(pos) => pos, + None => return Err(PlayerError::Seek("No position".into())), + }; + let seek_pos = time_pos + seek_amount; + + self.seek_to(seek_pos)?; + Ok(()) + } + + fn seek_to(&mut self, target_pos: Duration) -> Result<(), PlayerError> { + let start = if self.start.is_none() { + return Err(PlayerError::Seek("No START time".into())); + } else { + self.start.unwrap() + }; + + let end = if self.end.is_none() { + return Err(PlayerError::Seek("No END time".into())); + } else { + self.end.unwrap() + }; + + let adjusted_target = target_pos + start; + let clamped_target = adjusted_target.clamp(start, end); + + let seek_pos_clock = + ClockTime::from_useconds(clamped_target.num_microseconds().unwrap() as u64); + + self.set_gstreamer_volume(0.0); + self.playbin_mut() + .unwrap() + .seek_simple(gst::SeekFlags::FLUSH, seek_pos_clock)?; + self.set_gstreamer_volume(self.volume); + Ok(()) + } + + fn stop(&mut self) -> Result<(), PlayerError> { + self.pause()?; + self.ready()?; + + // Send the updated position to the tracker + self.playback_tx.send(PlaybackInfo::Idle).unwrap(); + + // Set all positions to none + *self.position.write().unwrap() = None; + self.start = None; + self.end = None; + Ok(()) + } + + fn message_channel(&self) -> &crossbeam::channel::Receiver { + &self.message_rx + } +} + +impl Drop for GStreamer { + /// Cleans up the `GStreamer` pipeline and the monitoring + /// thread when [Player] is dropped. + fn drop(&mut self) { + self.playbin_mut() + .unwrap() + .set_state(gst::State::Null) + .expect("Unable to set the pipeline to the `Null` state"); + let _ = self.playback_tx.send(PlaybackInfo::Finished); + } +} + +fn playback_monitor( + playbin: Arc>, + status_rx: Receiver, + playback_tx: Sender, + position: Arc>>, +) { + let mut stats = PlaybackInfo::Idle; + let mut pos_temp; + let mut sent_atf = false; + loop { + // Check for new messages to decide how to proceed + if let Ok(result) = status_rx.recv_timeout(std::time::Duration::from_millis(50)) { + stats = result + } + + pos_temp = playbin + .read() + .unwrap() + .query_position::() + .map(|pos| Duration::nanoseconds(pos.nseconds() as i64)); + + match stats { + PlaybackInfo::Playing { start, end } if pos_temp.is_some() => { + // Check if the current playback position is close to the end + let finish_point = end - Duration::milliseconds(2000); + if pos_temp.unwrap().num_microseconds() >= end.num_microseconds() { + println!("MONITOR: End of stream"); + let _ = playback_tx.try_send(PlayerCommand::EndOfStream); + playbin + .write() + .unwrap() + .set_state(gst::State::Ready) + .expect("Unable to set the pipeline state"); + sent_atf = false + } else if pos_temp.unwrap().num_microseconds() >= finish_point.num_microseconds() + && !sent_atf + { + println!("MONITOR: About to finish"); + let _ = playback_tx.try_send(PlayerCommand::AboutToFinish); + sent_atf = true; + } + + // This has to be done AFTER the current time in the file + // is calculated, or everything else is wrong + pos_temp = Some(pos_temp.unwrap() - start) + } + PlaybackInfo::Finished => { + println!("MONITOR: Shutting down"); + *position.write().unwrap() = None; + break; + } + PlaybackInfo::Idle | PlaybackInfo::Switching => sent_atf = false, + _ => (), + } + + *position.write().unwrap() = pos_temp; + } +} diff --git a/dmp-core/src/music_player/kira.rs b/dmp-core/src/music_player/kira.rs new file mode 100644 index 0000000..e69de29 diff --git a/dmp-core/src/music_player/player.rs b/dmp-core/src/music_player/player.rs new file mode 100644 index 0000000..4b74498 --- /dev/null +++ b/dmp-core/src/music_player/player.rs @@ -0,0 +1,99 @@ +use chrono::Duration; +use thiserror::Error; + +use crate::music_storage::library::URI; + +#[derive(Error, Debug)] +pub enum PlayerError { + #[error("player initialization failed: {0}")] + Init(String), + #[error("could not change playback state")] + StateChange(String), + #[error("seeking failed: {0}")] + Seek(String), + #[error("the file or source is not found")] + NotFound, + #[error("failed to build gstreamer item")] + Build, + #[error("poison error")] + Poison, + #[error("general player error")] + General(String), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PlayerState { + Playing, + Paused, + Ready, + Buffering(u8), + Null, + VoidPending, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PlayerCommand { + Play, + Pause, + EndOfStream, + AboutToFinish, +} + +pub trait Player { + /// Create a new player. + fn new() -> Result + where + Self: Sized; + + /// Get the currently playing [URI] from the player. + fn source(&self) -> &Option; + + /// Insert a new [`URI`] to be played. This method should be called at the + /// beginning to start playback of something, and once the [`PlayerCommand`] + /// indicates the track is about to finish to enqueue gaplessly. + /// + /// For backends which do not support gapless playback, `AboutToFinish` + /// will not be called, and the next [`URI`] should be enqueued once `Eos` + /// occurs. + fn enqueue_next(&mut self, next_track: &URI) -> Result<(), PlayerError>; + + /// Set the playback volume, accepts a float from `0` to `1`. + /// + /// Values outside the range of `0` to `1` will be capped. + fn set_volume(&mut self, volume: f64); + + /// Returns the current volume level, a float from `0` to `1`. + fn volume(&self) -> f64; + + /// If the player is paused or stopped, starts playback. + fn play(&mut self) -> Result<(), PlayerError>; + + /// If the player is playing, pause playback. + fn pause(&mut self) -> Result<(), PlayerError>; + + /// Stop the playback entirely, removing the current [`URI`] from the player. + fn stop(&mut self) -> Result<(), PlayerError>; + + /// Convenience function to check if playback is paused. + fn is_paused(&self) -> bool; + + /// Get the current playback position of the player. + fn position(&self) -> Option; + + /// Get the duration of the currently playing track. + fn duration(&self) -> Option; + + /// Seek relative to the current position. + /// + /// The position is capped at the duration of the song, and zero. + fn seek_by(&mut self, seek_amount: Duration) -> Result<(), PlayerError>; + + /// Seek absolutely within the song. + /// + /// The position is capped at the duration of the song, and zero. + fn seek_to(&mut self, target_pos: Duration) -> Result<(), PlayerError>; + + /// Return a reference to the player message channel, which can be cloned + /// in order to monitor messages from the player. + fn message_channel(&self) -> &crossbeam::channel::Receiver; +} diff --git a/dmp-core/src/music_storage/db_reader/common.rs b/dmp-core/src/music_storage/db_reader/common.rs new file mode 100644 index 0000000..368d86e --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/common.rs @@ -0,0 +1,49 @@ +use chrono::{DateTime, TimeZone, Utc}; + +pub fn get_bytes(iterator: &mut std::vec::IntoIter) -> [u8; S] { + let mut bytes = [0; S]; + + for byte in bytes.iter_mut().take(S) { + *byte = iterator.next().unwrap(); + } + + bytes +} + +pub fn get_bytes_vec(iterator: &mut std::vec::IntoIter, number: usize) -> Vec { + let mut bytes = Vec::new(); + + for _ in 0..number { + bytes.push(iterator.next().unwrap()); + } + + bytes +} + +/// Converts the windows DateTime into Chrono DateTime +pub fn get_datetime(iterator: &mut std::vec::IntoIter, topbyte: bool) -> DateTime { + let mut datetime_i64 = i64::from_le_bytes(get_bytes(iterator)); + + if topbyte { + // Zero the topmost byte + datetime_i64 &= 0x00FFFFFFFFFFFFFFF; + } + + if datetime_i64 <= 0 { + return Utc.timestamp_opt(0, 0).unwrap(); + } + + let unix_time_ticks = datetime_i64 - 621355968000000000; + + let unix_time_seconds = unix_time_ticks / 10000000; + + let unix_time_nanos = match (unix_time_ticks as f64 / 10000000.0) - unix_time_seconds as f64 + > 0.0 + { + true => ((unix_time_ticks as f64 / 10000000.0) - unix_time_seconds as f64) * 1000000000.0, + false => 0.0, + }; + + Utc.timestamp_opt(unix_time_seconds, unix_time_nanos as u32) + .unwrap() +} diff --git a/dmp-core/src/music_storage/db_reader/extern_library.rs b/dmp-core/src/music_storage/db_reader/extern_library.rs new file mode 100644 index 0000000..4312c4a --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/extern_library.rs @@ -0,0 +1,11 @@ +use std::path::Path; + +use crate::music_storage::library::Song; + +pub trait ExternalLibrary { + fn from_file(file: &Path) -> Self; + fn write(&self) { + unimplemented!(); + } + fn to_songs(&self) -> Vec; +} diff --git a/dmp-core/src/music_storage/db_reader/foobar/reader.rs b/dmp-core/src/music_storage/db_reader/foobar/reader.rs new file mode 100644 index 0000000..4d07e71 --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/foobar/reader.rs @@ -0,0 +1,200 @@ +use std::collections::BTreeMap; +use std::{fs::File, io::Read, path::Path, time::Duration}; + +use uuid::Uuid; + +use super::utils::meta_offset; +use crate::music_storage::db_reader::common::{get_bytes, get_bytes_vec}; +use crate::music_storage::db_reader::extern_library::ExternalLibrary; +use crate::music_storage::library::{Song, URI}; + +const MAGIC: [u8; 16] = [ + 0xE1, 0xA0, 0x9C, 0x91, 0xF8, 0x3C, 0x77, 0x42, 0x85, 0x2C, 0x3B, 0xCC, 0x14, 0x01, 0xD3, 0xF2, +]; + +#[derive(Debug)] +pub struct FoobarPlaylist { + metadata: Vec, + songs: Vec, +} + +impl ExternalLibrary for FoobarPlaylist { + /// Reads the entire MusicBee library and returns relevant values + /// as a `Vec` of `Song`s + fn from_file(file: &Path) -> Self { + let mut f = File::open(file).unwrap(); + let mut buffer = Vec::new(); + let mut retrieved_songs: Vec = Vec::new(); + + // Read the whole file + f.read_to_end(&mut buffer).unwrap(); + + let mut buf_iter = buffer.into_iter(); + + // Parse the header + let magic = get_bytes::<16>(&mut buf_iter); + if magic != MAGIC { + panic!("Magic bytes mismatch!"); + } + + let meta_size = i32::from_le_bytes(get_bytes(&mut buf_iter)) as usize; + let metadata = &get_bytes_vec(&mut buf_iter, meta_size); + let track_count = i32::from_le_bytes(get_bytes(&mut buf_iter)); + + // Read all the track fields + for _ in 0..track_count { + let flags = i32::from_le_bytes(get_bytes(&mut buf_iter)); + + let has_metadata = (0x01 & flags) != 0; + let has_padding = (0x04 & flags) != 0; + + let file_name_offset = i32::from_le_bytes(get_bytes(&mut buf_iter)) as usize; + let file_name = meta_offset(metadata, file_name_offset); + + let subsong_index = i32::from_le_bytes(get_bytes(&mut buf_iter)); + + if !has_metadata { + let track = FoobarPlaylistTrack { + file_name, + subsong_index, + ..Default::default() + }; + retrieved_songs.push(track); + continue; + } + + let file_size = i64::from_le_bytes(get_bytes(&mut buf_iter)); + + // TODO: Figure out how to make this work properly + let file_time = i64::from_le_bytes(get_bytes(&mut buf_iter)); + + let duration = Duration::from_nanos(u64::from_le_bytes(get_bytes(&mut buf_iter)) / 100); + + let rpg_album = f32::from_le_bytes(get_bytes(&mut buf_iter)); + + let rpg_track = f32::from_le_bytes(get_bytes(&mut buf_iter)); + + let rpk_album = f32::from_le_bytes(get_bytes(&mut buf_iter)); + + let rpk_track = f32::from_le_bytes(get_bytes(&mut buf_iter)); + + get_bytes::<4>(&mut buf_iter); + + let mut entries = Vec::new(); + let primary_count = i32::from_le_bytes(get_bytes(&mut buf_iter)); + let secondary_count = i32::from_le_bytes(get_bytes(&mut buf_iter)); + let _secondary_offset = i32::from_le_bytes(get_bytes(&mut buf_iter)); + + // Get primary keys + for _ in 0..primary_count { + println!("{}", i32::from_le_bytes(get_bytes(&mut buf_iter))); + + let key = meta_offset( + metadata, + i32::from_le_bytes(get_bytes(&mut buf_iter)) as usize, + ); + + entries.push((key, String::new())); + } + + // Consume unknown 32 bit value + println!("unk"); + get_bytes::<4>(&mut buf_iter); + + // Get primary values + for i in 0..primary_count { + println!("primkey {i}"); + + let value = meta_offset( + metadata, + i32::from_le_bytes(get_bytes(&mut buf_iter)) as usize, + ); + + entries[i as usize].1 = value; + } + + // Get secondary Keys + for _ in 0..secondary_count { + let key = meta_offset( + metadata, + i32::from_le_bytes(get_bytes(&mut buf_iter)) as usize, + ); + let value = meta_offset( + metadata, + i32::from_le_bytes(get_bytes(&mut buf_iter)) as usize, + ); + entries.push((key, value)); + } + + if has_padding { + get_bytes::<64>(&mut buf_iter); + } + + let track = FoobarPlaylistTrack { + flags, + file_name, + subsong_index, + file_size, + file_time, + duration, + rpg_album, + rpg_track, + rpk_album, + rpk_track, + entries, + }; + + retrieved_songs.push(track); + } + + Self { + songs: retrieved_songs, + metadata: metadata.clone(), + } + } + + fn to_songs(&self) -> Vec { + self.songs.iter().map(|song| song.find_song()).collect() + } +} + +#[derive(Debug, Default)] +pub struct FoobarPlaylistTrack { + flags: i32, + file_name: String, + subsong_index: i32, + file_size: i64, + file_time: i64, + duration: Duration, + rpg_album: f32, + rpg_track: f32, + rpk_album: f32, + rpk_track: f32, + entries: Vec<(String, String)>, +} + +impl FoobarPlaylistTrack { + fn find_song(&self) -> Song { + let location = URI::Local(self.file_name.clone().into()); + let internal_tags = Vec::new(); + + Song { + location: vec![location], + uuid: Uuid::new_v4(), + plays: 0, + skips: 0, + favorited: false, + banned: None, + rating: None, + format: None, + duration: self.duration, + play_time: Duration::from_secs(0), + last_played: None, + date_added: None, + date_modified: None, + album_art: Vec::new(), + tags: BTreeMap::new(), + internal_tags, + } + } +} diff --git a/dmp-core/src/music_storage/db_reader/foobar/utils.rs b/dmp-core/src/music_storage/db_reader/foobar/utils.rs new file mode 100644 index 0000000..278aa1a --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/foobar/utils.rs @@ -0,0 +1,15 @@ +pub fn meta_offset(metadata: &[u8], offset: usize) -> String { + let mut result_vec = Vec::new(); + + let mut i = offset; + loop { + if metadata[i] == 0x00 { + break; + } + + result_vec.push(metadata[i]); + i += 1; + } + + String::from_utf8_lossy(&result_vec).into() +} diff --git a/dmp-core/src/music_storage/db_reader/itunes/reader.rs b/dmp-core/src/music_storage/db_reader/itunes/reader.rs new file mode 100644 index 0000000..05075f2 --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/itunes/reader.rs @@ -0,0 +1,381 @@ +use file_format::FileFormat; +use lofty::{AudioFile, LoftyError, ParseOptions, Probe, TagType, TaggedFileExt}; +use quick_xml::events::Event; +use quick_xml::reader::Reader; +use std::collections::{BTreeMap, HashMap}; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration as StdDur; +use std::vec::Vec; +use uuid::Uuid; + +use chrono::prelude::*; + +use crate::music_storage::db_reader::extern_library::ExternalLibrary; +use crate::music_storage::library::{AlbumArt, BannedType, Service, Song, Tag, URI}; +use crate::music_storage::utils; + +use urlencoding::decode; + +#[derive(Debug, Default, Clone)] +pub struct ITunesLibrary { + tracks: Vec, +} +impl ITunesLibrary { + fn new() -> Self { + Default::default() + } + pub fn tracks(self) -> Vec { + self.tracks + } +} +impl ExternalLibrary for ITunesLibrary { + fn from_file(file: &Path) -> Self { + let mut reader = Reader::from_file(file).unwrap(); + reader.trim_text(true); + //count every event, for fun ig? + let mut count = 0; + //count for skipping useless beginning key + let mut count2 = 0; + //number of grabbed songs + let mut count3 = 0; + //number of IDs skipped + let mut count4 = 0; + + let mut buf = Vec::new(); + let mut skip = false; + + let mut converted_songs: Vec = Vec::new(); + + let mut song_tags: HashMap = HashMap::new(); + let mut key: String = String::new(); + let mut tagvalue: String = String::new(); + let mut key_selected = false; + + use std::time::Instant; + let now = Instant::now(); + + loop { + //push tag to song_tags map + if !key.is_empty() && !tagvalue.is_empty() { + song_tags.insert(key.clone(), tagvalue.clone()); + key.clear(); + tagvalue.clear(); + key_selected = false; + + //end the song to start a new one, and turn turn current song map into iTunesSong + if song_tags.contains_key(&"Location".to_string()) { + count3 += 1; + //check for skipped IDs + if &count3.to_string() + != song_tags.get_key_value(&"Track ID".to_string()).unwrap().1 + { + count3 += 1; + count4 += 1; + } + converted_songs.push(ITunesSong::from_hashmap(&mut song_tags).unwrap()); + song_tags.clear(); + skip = true; + } + } + match reader.read_event_into(&mut buf) { + Ok(Event::Start(_)) => { + count += 1; + count2 += 1; + } + Ok(Event::Text(e)) => { + if count < 17 && count != 10 { + continue; + } else if skip { + skip = false; + continue; + } + + let text = e.unescape().unwrap().to_string(); + if text == count2.to_string() && !key_selected { + continue; + } + + //Add the key/value depenidng on if the key is selected or not ⛩️sorry buzz + + match key_selected { + true => tagvalue.push_str(&text), + false => { + key.push_str(&text); + if !key.is_empty() { + key_selected = true + } else { + panic!("Key not selected?!") + } + } + } + } + Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e), + Ok(Event::Eof) => break, + _ => (), + } + buf.clear(); + } + let elasped = now.elapsed(); + println!( + "\n\niTunesReader grabbed {} songs in {:#?} seconds\nIDs Skipped: {}", + count3, + elasped.as_secs(), + count4 + ); + let mut lib = ITunesLibrary::new(); + lib.tracks.append(converted_songs.as_mut()); + lib + } + fn to_songs(&self) -> Vec { + let mut count = 0; + let mut bun: Vec = Vec::new(); + for track in &self.tracks { + //grab "other" tags + let mut tags_: BTreeMap = BTreeMap::new(); + for (key, val) in &track.tags { + tags_.insert(to_tag(key.clone()), val.clone()); + } + //make the path readable + let loc_ = if track.location.contains("file://localhost/") { + decode(track.location.strip_prefix("file://localhost/").unwrap()) + .unwrap() + .into_owned() + } else { + decode(track.location.as_str()).unwrap().into_owned() + }; + let loc = loc_.as_str(); + if File::open(loc).is_err() && !loc.contains("http") { + count += 1; + dbg!(loc); + continue; + } + + let location: URI = if track.location.contains("file://localhost/") { + URI::Local(PathBuf::from( + decode(track.location.strip_prefix("file://localhost/").unwrap()) + .unwrap() + .into_owned() + .as_str(), + )) + } else { + URI::Remote(Service::None, decode(&track.location).unwrap().into_owned()) + }; + let dur = match get_duration(Path::new(&loc)) { + Ok(e) => e, + Err(e) => { + dbg!(e); + StdDur::from_secs(0) + } + }; + let play_time_ = StdDur::from_secs(track.plays as u64 * dur.as_secs()); + + let internal_tags = Vec::new(); // TODO: handle internal tags generation + + let ny: Song = Song { + location: vec![location], + uuid: Uuid::new_v4(), + plays: track.plays, + skips: 0, + favorited: track.favorited, + banned: if track.banned { + Some(BannedType::All) + } else { + None + }, + rating: track.rating, + format: match FileFormat::from_file(PathBuf::from(&loc)) { + Ok(e) => Some(e), + Err(_) => None, + }, + duration: dur, + play_time: play_time_, + last_played: track.last_played, + date_added: track.date_added, + date_modified: track.date_modified, + album_art: match get_art(Path::new(&loc)) { + Ok(e) => e, + Err(_) => Vec::new(), + }, + tags: tags_, + internal_tags, + }; + // dbg!(&ny.tags); + bun.push(ny); + } + println!("skipped: {}", count); + bun + } +} +fn to_tag(string: String) -> Tag { + match string.to_lowercase().as_str() { + "name" => Tag::Title, + "album" => Tag::Album, + "artist" => Tag::Artist, + "album artist" => Tag::AlbumArtist, + "genre" => Tag::Genre, + "comment" => Tag::Comment, + "track number" => Tag::Track, + "disc number" => Tag::Disk, + _ => Tag::Key(string), + } +} +fn get_duration(file: &Path) -> Result { + let dur = match Probe::open(file)?.read() { + Ok(tagged_file) => tagged_file.properties().duration(), + + Err(_) => StdDur::from_secs(0), + }; + Ok(dur) +} +fn get_art(file: &Path) -> Result, LoftyError> { + let mut album_art: Vec = Vec::new(); + + let blank_tag = &lofty::Tag::new(TagType::Id3v2); + let normal_options = ParseOptions::new().parsing_mode(lofty::ParsingMode::Relaxed); + let tagged_file: lofty::TaggedFile; + + let tag = match Probe::open(file)?.options(normal_options).read() { + Ok(e) => { + tagged_file = e; + match tagged_file.primary_tag() { + Some(primary_tag) => primary_tag, + + None => match tagged_file.first_tag() { + Some(first_tag) => first_tag, + None => blank_tag, + }, + } + } + Err(_) => blank_tag, + }; + let mut img = match utils::find_images(file) { + Ok(e) => e, + Err(_) => Vec::new(), + }; + if !img.is_empty() { + album_art.append(img.as_mut()); + } + + for (i, _art) in tag.pictures().iter().enumerate() { + let new_art = AlbumArt::Embedded(i); + + album_art.push(new_art) + } + + Ok(album_art) +} + +#[derive(Debug, Clone, Default)] +pub struct ITunesSong { + pub id: i32, + pub plays: i32, + pub favorited: bool, + pub banned: bool, + pub rating: Option, + pub format: Option, + pub song_type: Option, + pub last_played: Option>, + pub date_added: Option>, + pub date_modified: Option>, + pub tags: BTreeMap, + pub location: String, +} + +impl ITunesSong { + pub fn new() -> ITunesSong { + Default::default() + } + + fn from_hashmap(map: &mut HashMap) -> Result { + let mut song = ITunesSong::new(); + //get the path with the first bit chopped off + let path_: String = map.get_key_value("Location").unwrap().1.clone(); + let track_type: String = map.get_key_value("Track Type").unwrap().1.clone(); + let path: String = match track_type.as_str() { + "File" => { + if path_.contains("file://localhost/") { + path_.strip_prefix("file://localhost/").unwrap(); + } + path_ + } + "URL" => path_, + _ => path_, + }; + + for (key, value) in map { + match key.as_str() { + "Track ID" => song.id = value.parse().unwrap(), + "Location" => song.location = path.to_string(), + "Play Count" => song.plays = value.parse().unwrap(), + "Love" => { + //check if the track is (L)Loved or (B)Banned + match value.as_str() { + "L" => song.favorited = true, + "B" => song.banned = false, + _ => continue, + } + } + "Rating" => song.rating = Some(value.parse().unwrap()), + "Kind" => song.format = Some(value.to_string()), + "Play Date UTC" => { + song.last_played = Some(DateTime::::from_str(value).unwrap()) + } + "Date Added" => song.date_added = Some(DateTime::::from_str(value).unwrap()), + "Date Modified" => { + song.date_modified = Some(DateTime::::from_str(value).unwrap()) + } + "Track Type" => song.song_type = Some(value.to_string()), + _ => { + song.tags.insert(key.to_string(), value.to_string()); + } + } + } + // println!("{:.2?}", song); + Ok(song) + } +} + +#[cfg(test)] +mod tests { + use std::{ + path::{Path, PathBuf}, + sync::{Arc, RwLock}, + }; + + use crate::{ + config::{Config, ConfigLibrary}, + music_storage::{db_reader::extern_library::ExternalLibrary, library::MusicLibrary}, + }; + + use super::ITunesLibrary; + + #[test] + fn itunes_lib_test() { + let mut config = Config::read_file(PathBuf::from("test-config/config_test.json")).unwrap(); + let config_lib = ConfigLibrary::new( + PathBuf::from("test-config/library2"), + String::from("library2"), + None, + ); + config.libraries.libraries.push(config_lib.clone()); + + let songs = ITunesLibrary::from_file(Path::new("test-config\\iTunesLib.xml")).to_songs(); + + let mut library = MusicLibrary::init( + config.libraries.get_default().unwrap().path.clone(), + config_lib.uuid, + ) + .unwrap(); + + songs + .iter() + .for_each(|song| library.add_song(song.to_owned()).unwrap()); + + config.write_file().unwrap(); + library + .save(config.libraries.get_default().unwrap().path.clone()) + .unwrap(); + } +} diff --git a/dmp-core/src/music_storage/db_reader/mod.rs b/dmp-core/src/music_storage/db_reader/mod.rs new file mode 100644 index 0000000..be1841f --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/mod.rs @@ -0,0 +1,13 @@ +pub mod foobar { + pub mod reader; + pub mod utils; +} +pub mod musicbee { + pub mod reader; + pub mod utils; +} +pub mod itunes { + pub mod reader; +} +pub mod common; +pub mod extern_library; diff --git a/dmp-core/src/music_storage/db_reader/musicbee/reader.rs b/dmp-core/src/music_storage/db_reader/musicbee/reader.rs new file mode 100644 index 0000000..9811862 --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/musicbee/reader.rs @@ -0,0 +1,220 @@ +use super::utils::get_string; +use crate::music_storage::db_reader::common::{get_bytes, get_datetime}; +use chrono::{DateTime, Utc}; +use std::fs::File; +use std::io::prelude::*; +use std::time::Duration; + +pub struct MusicBeeDatabase { + path: String, +} + +impl MusicBeeDatabase { + pub fn new(path: String) -> MusicBeeDatabase { + MusicBeeDatabase { path } + } + + /// Reads the entire MusicBee library and returns relevant values + /// as a `Vec` of `Song`s + pub fn read(&self) -> Result, Box> { + let mut f = File::open(&self.path).unwrap(); + let mut buffer = Vec::new(); + let mut retrieved_songs: Vec = Vec::new(); + + // Read the whole file + f.read_to_end(&mut buffer)?; + + let mut buf_iter = buffer.into_iter(); + + // Get the song count from the first 4 bytes + // and then right shift it by 8 for some reason + let mut database_song_count = i32::from_le_bytes(get_bytes(&mut buf_iter)); + database_song_count >>= 8; + + let mut song_count = 0; + loop { + // If the file designation is 1, then the end of the database + // has been reached + let file_designation = match buf_iter.next() { + Some(1) => break, + Some(value) => value, + None => break, + }; + + song_count += 1; + + // Get the file status. Unknown what this means + let status = buf_iter.next().unwrap(); + + buf_iter.next(); // Read in a byte to throw it away + + // Get the play count + let play_count = u16::from_le_bytes(get_bytes(&mut buf_iter)); + + // Get the time the song was last played, stored as a signed 64 bit number of microseconds + let last_played = get_datetime(buf_iter.by_ref(), true); + + // Get the number of times the song was skipped + let skip_count = u16::from_le_bytes(get_bytes(&mut buf_iter)); + + // Get the path to the song + let path = get_string(buf_iter.by_ref()); + + // Get the file size + let file_size = i32::from_le_bytes(get_bytes(&mut buf_iter)); + + // Get the sample rate + let sample_rate = i32::from_le_bytes(get_bytes(&mut buf_iter)); + + // Get the channel count + let channel_count = buf_iter.next().unwrap(); + + // Get the bitrate type (CBR, VBR, etc.) + let bitrate_type = buf_iter.next().unwrap(); + + // Get the actual bitrate + let bitrate = i16::from_le_bytes(get_bytes(&mut buf_iter)); + + // Get the track length in milliseconds + let track_length = + Duration::from_millis(i32::from_le_bytes(get_bytes(&mut buf_iter)) as u64); + + // Get the date added and modified in the same format + let date_added = get_datetime(buf_iter.by_ref(), true); + let date_modified = get_datetime(buf_iter.by_ref(), true); + + // Gets artwork information + // + // Artworks are stored as chunks describing the type + // (embedded, file), and some other information. + let mut artwork: Vec = vec![]; + loop { + let artwork_type = buf_iter.next().unwrap(); + if artwork_type > 253 { + break; + } + + let unknown_string = get_string(buf_iter.by_ref()); + let storage_mode = buf_iter.next().unwrap(); + let storage_path = get_string(buf_iter.by_ref()); + + artwork.push(MusicBeeAlbumArt { + artwork_type, + unknown_string, + storage_mode, + storage_path, + }); + } + + buf_iter.next(); // Read in a byte to throw it away + + // Gets all the tags on the song in the database + let mut tags: Vec = vec![]; + loop { + // If the tag code is 0, the end of the block has been reached, so break. + // + // If the tag code is 255, it pertains to some CUE file values that are not known + // throw away these values + let tag_code = match buf_iter.next() { + Some(0) => break, + Some(255) => { + let repeats = u16::from_le_bytes(get_bytes(&mut buf_iter)); + for _ in 0..(repeats * 13) - 2 { + buf_iter.next().unwrap(); + } + + 255 + } + Some(value) => value, + None => panic!(), + }; + + // Get the string value of the tag + let tag_value = get_string(buf_iter.by_ref()); + tags.push(MusicBeeTag { + tag_code, + tag_value, + }); + } + + // Construct the finished song and add it to the vec + let constructed_song = MusicBeeSong { + file_designation, + status, + play_count, + last_played, + skip_count, + path, + file_size, + sample_rate, + channel_count, + bitrate_type, + bitrate, + track_length, + date_added, + date_modified, + artwork, + tags, + }; + + retrieved_songs.push(constructed_song); + } + + println!("The database claims you have: {database_song_count} songs\nThe retrieved number is: {song_count} songs"); + + match database_song_count == song_count { + true => Ok(retrieved_songs), + false => Err("Song counts do not match!".into()), + } + } +} + +#[derive(Debug)] +pub struct MusicBeeTag { + tag_code: u8, + tag_value: String, +} + +#[derive(Debug)] +pub struct MusicBeeAlbumArt { + artwork_type: u8, + unknown_string: String, + storage_mode: u8, + storage_path: String, +} + +#[derive(Debug)] +pub struct MusicBeeSong { + file_designation: u8, + status: u8, + play_count: u16, + pub last_played: DateTime, + skip_count: u16, + path: String, + file_size: i32, + sample_rate: i32, + channel_count: u8, + bitrate_type: u8, + bitrate: i16, + track_length: Duration, + date_added: DateTime, + date_modified: DateTime, + + /* Album art stuff */ + artwork: Vec, + + /* All tags */ + tags: Vec, +} + +impl MusicBeeSong { + pub fn get_tag_code(self, code: u8) -> Option { + for tag in &self.tags { + if tag.tag_code == code { + return Some(tag.tag_value.clone()); + } + } + + None + } +} diff --git a/dmp-core/src/music_storage/db_reader/musicbee/utils.rs b/dmp-core/src/music_storage/db_reader/musicbee/utils.rs new file mode 100644 index 0000000..65d6333 --- /dev/null +++ b/dmp-core/src/music_storage/db_reader/musicbee/utils.rs @@ -0,0 +1,29 @@ +use leb128; + +/// Gets a string from the MusicBee database format +/// +/// The length of the string is defined by an LEB128 encoded value at the beginning, followed by the string of that length +pub fn get_string(iterator: &mut std::vec::IntoIter) -> String { + let mut string_length = iterator.next().unwrap() as usize; + if string_length == 0 { + return String::new(); + } + + // Decode the LEB128 value + let mut leb_bytes: Vec = vec![]; + loop { + leb_bytes.push(string_length as u8); + + if string_length >> 7 != 1 { + break; + } + string_length = iterator.next().unwrap() as usize; + } + string_length = leb128::read::unsigned(&mut leb_bytes.as_slice()).unwrap() as usize; + + let mut string_bytes = vec![]; + for _ in 0..string_length { + string_bytes.push(iterator.next().unwrap()); + } + String::from_utf8(string_bytes).unwrap() +} diff --git a/dmp-core/src/music_storage/library.rs b/dmp-core/src/music_storage/library.rs new file mode 100644 index 0000000..fc24915 --- /dev/null +++ b/dmp-core/src/music_storage/library.rs @@ -0,0 +1,1243 @@ +use super::playlist::PlaylistFolder; +// Crate things +use super::utils::{find_images, normalize, read_file, write_file}; +use crate::config::Config; + +use std::cmp::Ordering; +// Various std things +use std::collections::{BTreeMap, HashMap}; +use std::error::Error; +use std::ops::ControlFlow::{Break, Continue}; +use std::vec::IntoIter; + +// Files +use file_format::{FileFormat, Kind}; +use glib::filename_to_uri; + +use lofty::{AudioFile, ItemKey, ItemValue, ParseOptions, Probe, TagType, TaggedFileExt}; +use rcue::parser::parse_from_file; +use std::fs; +use std::path::{Path, PathBuf}; +use uuid::Uuid; +use walkdir::WalkDir; + +// Time +use chrono::{serde::ts_milliseconds_option, DateTime, Utc}; +use std::time::Duration; + +// Serialization/Compression +use base64::{engine::general_purpose, Engine as _}; +use serde::{Deserialize, Serialize}; + +// Fun parallel stuff +use rayon::prelude::*; +use std::sync::{Arc, Mutex, RwLock}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub enum AlbumArt { + Embedded(usize), + External(URI), +} + +impl AlbumArt { + pub fn uri(&self) -> Option<&URI> { + match self { + Self::Embedded(_) => None, + Self::External(uri) => Some(uri), + } + } +} + +/// A tag for a song +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tag { + Title, + Album, + Artist, + AlbumArtist, + Genre, + Comment, + Track, + Disk, + Key(String), + Field(String), +} + +impl ToString for Tag { + fn to_string(&self) -> String { + match self { + Self::Title => "TrackTitle".into(), + Self::Album => "AlbumTitle".into(), + Self::Artist => "TrackArtist".into(), + Self::AlbumArtist => "AlbumArtist".into(), + Self::Genre => "Genre".into(), + Self::Comment => "Comment".into(), + Self::Track => "TrackNumber".into(), + Self::Disk => "DiscNumber".into(), + Self::Key(key) => key.into(), + Self::Field(f) => f.into(), + } + } +} + +/// A field within a Song struct +#[derive(Debug)] +pub enum Field { + Location(URI), + Plays(i32), + Skips(i32), + Favorited(bool), + Rating(u8), + Format(FileFormat), + Duration(Duration), + PlayTime(Duration), + LastPlayed(DateTime), + DateAdded(DateTime), + DateModified(DateTime), +} + +impl ToString for Field { + fn to_string(&self) -> String { + match self { + Self::Location(location) => location.to_string(), + Self::Plays(plays) => plays.to_string(), + Self::Skips(skips) => skips.to_string(), + Self::Favorited(fav) => fav.to_string(), + Self::Rating(rating) => rating.to_string(), + Self::Format(format) => match format.short_name() { + Some(name) => name.to_string(), + None => format.to_string(), + }, + Self::Duration(duration) => duration.as_millis().to_string(), + Self::PlayTime(time) => time.as_millis().to_string(), + Self::LastPlayed(last) => last.to_rfc2822(), + Self::DateAdded(added) => added.to_rfc2822(), + Self::DateModified(modified) => modified.to_rfc2822(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum InternalTag { + DoNotTrack(DoNotTrack), + SongType(SongType), + SongLink(Uuid, SongType), + // Volume Adjustment from -100% to 100% + VolumeAdjustment(i8), +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[non_exhaustive] +pub enum BannedType { + Shuffle, + All, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum DoNotTrack { + // TODO: add services to not track + LastFM, + LibreFM, + MusicBrainz, + Discord, +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum SongType { + // TODO: add MORE?! song types + #[default] + Main, + Instrumental, + Remix, + Custom(String), +} + +/// Stores information about a single song +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Song { + pub location: Vec, + pub uuid: Uuid, + pub plays: i32, + pub skips: i32, + pub favorited: bool, + pub banned: Option, + pub rating: Option, + pub format: Option, + pub duration: Duration, + pub play_time: Duration, + #[serde(with = "ts_milliseconds_option")] + pub last_played: Option>, + #[serde(with = "ts_milliseconds_option")] + pub date_added: Option>, + #[serde(with = "ts_milliseconds_option")] + pub date_modified: Option>, + pub album_art: Vec, + pub tags: BTreeMap, + pub internal_tags: Vec, +} + +impl Song { + /// Get a tag's value + /// + /// ``` + /// use dango_core::music_storage::music_db::Tag; + /// // Assuming an already created song: + /// + /// let tag = this_song.get_tag(Tag::Title); + /// + /// assert_eq!(tag, "Some Song Title"); + /// ``` + pub fn get_tag(&self, target_key: &Tag) -> Option<&String> { + self.tags.get(target_key) + } + + /// Gets an internal field from a song + pub fn get_field(&self, target_field: &str) -> Option { + let lower_target = target_field.to_lowercase(); + match lower_target.as_str() { + "location" => Some(Field::Location(self.primary_uri().unwrap().0.clone())), //TODO: make this not unwrap() + "plays" => Some(Field::Plays(self.plays)), + "skips" => Some(Field::Skips(self.skips)), + "favorited" => Some(Field::Favorited(self.favorited)), + "rating" => self.rating.map(Field::Rating), + "duration" => Some(Field::Duration(self.duration)), + "play_time" => Some(Field::PlayTime(self.play_time)), + "format" => self.format.map(Field::Format), + _ => todo!(), // Other field types are not yet supported + } + } + + /// Sets the value of a tag in the song + pub fn set_tag(&mut self, target_key: Tag, new_value: String) { + self.tags.insert(target_key, new_value); + } + + /// Deletes a tag from the song + pub fn remove_tag(&mut self, target_key: &Tag) { + self.tags.remove(target_key); + } + + /// Creates a `Song` from a music file + pub fn from_file>(target_file: &P) -> Result> { + let normal_options = ParseOptions::new().parsing_mode(lofty::ParsingMode::Relaxed); + + let blank_tag = &lofty::Tag::new(TagType::Id3v2); + let tagged_file: lofty::TaggedFile; + let mut duration = Duration::from_secs(0); + let tag = match Probe::open(target_file)?.options(normal_options).read() { + Ok(file) => { + tagged_file = file; + + duration = tagged_file.properties().duration(); + + // Ensure the tags exist, if not, insert blank data + match tagged_file.primary_tag() { + Some(primary_tag) => primary_tag, + + None => match tagged_file.first_tag() { + Some(first_tag) => first_tag, + None => blank_tag, + }, + } + } + + Err(_) => blank_tag, + }; + + let mut tags: BTreeMap = BTreeMap::new(); + for item in tag.items() { + let key = match item.key() { + ItemKey::TrackTitle => Tag::Title, + ItemKey::TrackNumber => Tag::Track, + ItemKey::TrackArtist => Tag::Artist, + ItemKey::AlbumArtist => Tag::AlbumArtist, + ItemKey::Genre => Tag::Genre, + ItemKey::Comment => Tag::Comment, + ItemKey::AlbumTitle => Tag::Album, + ItemKey::DiscNumber => Tag::Disk, + ItemKey::Unknown(unknown) + if unknown == "ACOUSTID_FINGERPRINT" || unknown == "Acoustid Fingerprint" => + { + continue + } + ItemKey::Unknown(unknown) => Tag::Key(unknown.to_string()), + custom => Tag::Key(format!("{:?}", custom)), + }; + + let value = match item.value() { + ItemValue::Text(value) => value.clone(), + ItemValue::Locator(value) => value.clone(), + ItemValue::Binary(bin) => format!("BIN#{}", general_purpose::STANDARD.encode(bin)), + }; + + tags.insert(key, value); + } + + // Get all the album artwork information from the file + let mut album_art: Vec = Vec::new(); + for (i, _art) in tag.pictures().iter().enumerate() { + let new_art = AlbumArt::Embedded(i); + + album_art.push(new_art) + } + + // Find images around the music file that can be used + let found_images = find_images(target_file.as_ref()).unwrap(); + album_art.extend_from_slice(&found_images); + + // Get the format as a string + let format: Option = match FileFormat::from_file(target_file) { + Ok(fmt) => Some(fmt), + Err(_) => None, + }; + + // TODO: Fix error handling + let binding = fs::canonicalize(target_file).unwrap(); + + // TODO: Handle creation of internal tag: Song Type and Song Links + let internal_tags = { Vec::new() }; + let new_song = Song { + location: vec![URI::Local(binding)], + uuid: Uuid::new_v4(), + plays: 0, + skips: 0, + favorited: false, + banned: None, + rating: None, + format, + duration, + play_time: Duration::from_secs(0), + last_played: None, + date_added: Some(chrono::offset::Utc::now()), + date_modified: Some(chrono::offset::Utc::now()), + tags, + album_art, + internal_tags, + }; + Ok(new_song) + } + + /// creates a `Vec` from a cue file + pub fn from_cue(cuesheet: &Path) -> Result, Box> { + let mut tracks = Vec::new(); + + let cue_data = parse_from_file(&cuesheet.to_string_lossy(), false).unwrap(); + + // Get album level information + let album_title = &cue_data.title; + let album_artist = &cue_data.performer; + + let parent_dir = cuesheet.parent().expect("The file has no parent path??"); + for file in cue_data.files.iter() { + let audio_location = &parent_dir.join(file.file.clone()); + + if !audio_location.exists() { + continue; + } + + let next_track = file.tracks.clone(); + let mut next_track = next_track.iter().skip(1); + for (i, track) in file.tracks.iter().enumerate() { + // Get the track timing information + let pregap = match track.pregap { + Some(pregap) => pregap, + None => Duration::from_secs(0), + }; + let postgap = match track.postgap { + Some(postgap) => postgap, + None => Duration::from_secs(0), + }; + + let mut start; + if track.indices.len() > 1 { + start = track.indices[1].1; + } else { + start = track.indices[0].1; + } + if !start.is_zero() { + start -= pregap; + } + + let duration = match next_track.next() { + Some(future) => match future.indices.first() { + Some(val) => val.1 - start, + None => Duration::from_secs(0), + }, + None => match lofty::read_from_path(audio_location) { + Ok(tagged_file) => tagged_file.properties().duration() - start, + + Err(_) => match Probe::open(audio_location)?.read() { + Ok(tagged_file) => tagged_file.properties().duration() - start, + + Err(_) => Duration::from_secs(0), + }, + }, + }; + let end = start + duration + postgap; + + // Get the format as a string + let format: Option = match FileFormat::from_file(audio_location) { + Ok(fmt) => Some(fmt), + Err(_) => None, + }; + + // Get some useful tags + let mut tags: BTreeMap = BTreeMap::new(); + match album_title { + Some(title) => { + tags.insert(Tag::Album, title.clone()); + } + None => (), + } + match album_artist { + Some(artist) => { + tags.insert(Tag::Artist, artist.clone()); + } + None => (), + } + tags.insert(Tag::Track, track.no.parse().unwrap_or((i + 1).to_string())); + match track.title.clone() { + Some(title) => tags.insert(Tag::Title, title), + None => match track.isrc.clone() { + Some(title) => tags.insert(Tag::Title, title), + None => { + let namestr = format!("{} - {}", i, file.file.clone()); + tags.insert(Tag::Title, namestr) + } + }, + }; + match track.performer.clone() { + Some(artist) => tags.insert(Tag::Artist, artist), + None => None, + }; + + // Find images around the music file that can be used + let album_art = find_images(&audio_location.to_path_buf()).unwrap(); + + let new_song = Song { + location: vec![URI::Cue { + location: audio_location.clone(), + index: i, + start, + end, + }], + uuid: Uuid::new_v4(), + plays: 0, + skips: 0, + favorited: false, + banned: None, + rating: None, + format, + duration, + play_time: Duration::from_secs(0), + last_played: None, + date_added: Some(chrono::offset::Utc::now()), + date_modified: Some(chrono::offset::Utc::now()), + tags, + album_art, + internal_tags: Vec::new(), + }; + tracks.push((new_song, audio_location.clone())); + } + } + Ok(tracks) + } + + /// Returns a reference to the first valid URI in the song, and any invalid URIs that come before it, or errors if there are no valid URIs + #[allow(clippy::type_complexity)] + pub fn primary_uri(&self) -> Result<(&URI, Option>), Box> { + let mut invalid_uris = Vec::new(); + let mut valid_uri = None; + + for uri in &self.location { + if uri.exists()? { + valid_uri = Some(uri); + break; + } else { + invalid_uris.push(uri); + } + } + match valid_uri { + Some(uri) => Ok(( + uri, + if !invalid_uris.is_empty() { + Some(invalid_uris) + } else { + None + }, + )), + None => Err("No valid URIs for this song".into()), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum URI { + Local(PathBuf), + Cue { + location: PathBuf, + index: usize, + start: Duration, + end: Duration, + }, + Remote(Service, String), +} + +impl URI { + pub fn index(&self) -> Result<&usize, Box> { + match self { + URI::Local(_) => Err("\"Local\" has no stored index".into()), + URI::Remote(_, _) => Err("\"Remote\" has no stored index".into()), + URI::Cue { index, .. } => Ok(index), + } + } + + /// Returns the start time of a CUEsheet song, or an + /// error if the URI is not a Cue variant + pub fn start(&self) -> Result<&Duration, Box> { + match self { + URI::Local(_) => Err("\"Local\" has no starting time".into()), + URI::Remote(_, _) => Err("\"Remote\" has no starting time".into()), + URI::Cue { start, .. } => Ok(start), + } + } + + /// Returns the end time of a CUEsheet song, or an + /// error if the URI is not a Cue variant + pub fn end(&self) -> Result<&Duration, Box> { + match self { + URI::Local(_) => Err("\"Local\" has no starting time".into()), + URI::Remote(_, _) => Err("\"Remote\" has no starting time".into()), + URI::Cue { end, .. } => Ok(end), + } + } + + /// Returns the location as a PathBuf + pub fn path(&self) -> PathBuf { + match self { + URI::Local(location) => location.clone(), + URI::Cue { location, .. } => location.clone(), + URI::Remote(_, location) => PathBuf::from(location), + } + } + + pub fn as_uri(&self) -> String { + let path_str = match self { + URI::Local(location) => filename_to_uri(location, None) + .expect("couldn't convert path to URI") + .to_string(), + URI::Cue { location, .. } => filename_to_uri(location, None) + .expect("couldn't convert path to URI") + .to_string(), + URI::Remote(_, location) => location.clone(), + }; + path_str.to_string() + } + + pub fn as_path(&self) -> Result<&PathBuf, Box> { + if let Self::Local(path) = self { + Ok(path) + } else { + Err("This URI is not local!".into()) + } + } + + pub fn exists(&self) -> Result { + match self { + URI::Local(loc) => loc.try_exists(), + URI::Cue { location, .. } => location.try_exists(), + URI::Remote(_, _loc) => Ok(true), // TODO: Investigate a way to do this? + } + } +} + +impl ToString for URI { + fn to_string(&self) -> String { + let path_str = match self { + URI::Local(location) => location.as_path().to_string_lossy(), + URI::Cue { location, .. } => location.as_path().to_string_lossy(), + URI::Remote(_, location) => location.into(), + }; + path_str.to_string() + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Service { + InternetRadio, + Spotify, + Youtube, + None, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Album { + title: String, + artist: Option, + cover: Option, + discs: BTreeMap>, +} + +#[allow(clippy::len_without_is_empty)] +impl Album { + //returns the Album title + pub fn title(&self) -> &String { + &self.title + } + + /// Returns the album cover as an AlbumArt struct, if it exists + fn cover(&self) -> &Option { + &self.cover + } + + /// Returns the Album Artist, if they exist + pub fn artist(&self) -> &Option { + &self.artist + } + + pub fn discs(&self) -> &BTreeMap> { + &self.discs + } + /// Returns the specified track at `index` from the album, returning + /// an error if the track index is out of range + pub fn track(&self, disc: u16, index: usize) -> Option<&(u16, Uuid)> { + self.discs.get(&disc)?.get(index) + } + + fn tracks(&self) -> Vec<(u16, Uuid)> { + let mut songs = Vec::new(); + for disc in self.discs.values() { + songs.extend_from_slice(&disc) + } + songs + } + + /// Returns the number of songs in the album + pub fn len(&self) -> usize { + let mut total = 0; + for disc in self.discs.values() { + total += disc.len(); + } + total + } +} + +impl IntoIterator for Album { + type Item = AlbumTrack; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let mut vec = vec![]; + + for (disc, mut tracks) in self.discs { + tracks.par_sort_by(|a, b| a.0.cmp(&b.0)); + + let mut tracks = tracks + .into_iter() + .map(|(track, uuid)| AlbumTrack { disc, track, uuid }) + .collect::>(); + + vec.append(&mut tracks); + } + vec.into_iter() + } +} + +pub struct AlbumTrack { + disc: u16, + track: u16, + uuid: Uuid, +} + +impl AlbumTrack { + pub fn disc(&self) -> &u16 { + &self.disc + } + + pub fn track(&self) -> &u16 { + &self.track + } + + pub fn uuid(&self) -> &Uuid { + &self.uuid + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MusicLibrary { + pub name: String, + pub uuid: Uuid, + pub library: Vec, + pub playlists: PlaylistFolder, + pub backup_songs: Vec, // maybe move this to the config instead? +} + +impl MusicLibrary { + const BLOCKED_EXTENSIONS: &'static [&'static str] = &["vob", "log", "txt", "sf2"]; + + /// Create a new library from a name and [Uuid] + fn new(name: String, uuid: Uuid) -> Self { + MusicLibrary { + name, + uuid, + library: Vec::new(), + playlists: PlaylistFolder::default(), + backup_songs: Vec::new(), + } + } + + /// Initialize the database + /// + /// If the database file already exists, return the [MusicLibrary], otherwise create + /// the database first. This needs to be run before anything else to retrieve + /// the [MusicLibrary] Vec + pub fn init(path: PathBuf, uuid: Uuid) -> Result> { + let library: MusicLibrary = match path.exists() { + true => read_file(path)?, + false => { + // If the library does not exist, re-create it + let lib = MusicLibrary::new(String::new(), uuid); + write_file(&lib, path)?; + lib + } + }; + Ok(library) + } + + //#[cfg(debug_assertions)] // We probably wouldn't want to use this for real, but maybe it would have some utility? + pub fn from_path>(path: &P) -> Result> { + let path: PathBuf = path.as_ref().to_path_buf(); + let library: MusicLibrary = match path.exists() { + true => read_file(path)?, + false => { + let lib = MusicLibrary::new(String::new(), Uuid::new_v4()); + write_file(&lib, path)?; + lib + } + }; + Ok(library) + } + + /// Serializes the database out to the file specified in the config + pub fn save_path>(&self, path: &P) -> Result<(), Box> { + let path = path.as_ref(); + match path.try_exists() { + Ok(_) => write_file(self, path)?, + Err(error) => return Err(error.into()), + } + + Ok(()) + } + + /// Serializes the database out to the file specified in the config + pub fn save(&self, path: PathBuf) -> Result<(), Box> { + match path.try_exists() { + Ok(_) => write_file(self, path)?, + Err(error) => return Err(error.into()), + } + + Ok(()) + } + + /// Returns the library size in number of tracks + pub fn len_tracks(&self) -> usize { + self.library.len() + } + + /// Returns the library size in number of albums + pub fn len_albums(&self) -> usize { + self.albums().len() + } + + /// Queries for a [Song] by its [URI], returning a single `Song` + /// with the `URI` that matches along with its position in the library + #[inline(always)] + pub fn query_uri(&self, path: &URI) -> Option<(&Song, usize)> { + let result = self + .library + .par_iter() + .enumerate() + .try_for_each(|(i, track)| { + for location in &track.location { + //TODO: check that this works + if path == location { + return Break((track, i)); + } + } + Continue(()) + }); + + match result { + Break(song) => Some(song), + Continue(_) => None, + } + } + + /// Queries for a [Song] by its [Uuid], returning a single `Song` + /// with the `Uuid` that matches along with its position in the library + pub fn query_uuid(&self, uuid: &Uuid) -> Option<(&Song, usize)> { + let result = self + .library + .par_iter() + .enumerate() + .try_for_each(|(i, track)| { + if uuid == &track.uuid { + return std::ops::ControlFlow::Break((track, i)); + } + Continue(()) + }); + + match result { + Break(song) => Some(song), + Continue(_) => None, + } + } + + /// Queries for a [Song] by its [PathBuf], returning a `Vec<&Song>` + /// with matching `PathBuf`s + fn query_path(&self, path: PathBuf) -> Option> { + let result: Arc>> = Arc::new(Mutex::new(Vec::new())); + self.library.par_iter().for_each(|track| { + if path == track.primary_uri().unwrap().0.path() { + //TODO: make this also not unwrap + Arc::clone(&result).lock().unwrap().push(track); + } + }); + + if result.lock().unwrap().len() > 0 { + Some(Arc::try_unwrap(result).unwrap().into_inner().unwrap()) + } else { + None + } + } + + /// Finds all the audio files within a specified folder + pub fn scan_folder>( + &mut self, + target_path: &P, + ) -> Result> { + let mut total = 0; + let mut errors = 0; + for target_file in WalkDir::new(target_path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = target_file.path(); + + // Ensure the target is a file and not a directory, + // if it isn't a file, skip this loop + if !path.is_file() { + continue; + } + + // Check if the file path is already in the db + if self.query_uri(&URI::Local(path.to_path_buf())).is_some() { + continue; + } + + let format = FileFormat::from_file(path)?; + let extension = match path.extension() { + Some(ext) => ext.to_string_lossy().to_ascii_lowercase(), + None => String::new(), + }; + + // If it's a normal file, add it to the database + // if it's a cuesheet, do a bunch of fancy stuff + if (format.kind() == Kind::Audio || format.kind() == Kind::Video) + && !Self::BLOCKED_EXTENSIONS.contains(&extension.as_str()) + { + match self.add_file(target_file.path()) { + Ok(_) => total += 1, + Err(_error) => { + errors += 1; + println!("{:?}: {}", target_file.file_name(), _error) + } // TODO: Handle more of these errors + }; + } else if extension == "cue" { + total += match self.add_cuesheet(target_file.path()) { + Ok(added) => added, + Err(_error) => { + errors += 1; + println!("{:?}: {}", target_file.file_name(), _error); + 0 + } + } + } + } + + println!("Total scanning errors: {}", errors); + + Ok(total) + } + + pub fn remove_missing(&mut self) { + let target_removals = Arc::new(Mutex::new(Vec::new())); + self.library.par_iter().for_each(|t| { + for location in &t.location { + if !location.exists().unwrap() { + Arc::clone(&target_removals) + .lock() + .unwrap() + .push(location.clone()); + } + } + }); + + let target_removals = Arc::try_unwrap(target_removals) + .unwrap() + .into_inner() + .unwrap(); + for location in target_removals { + self.remove_uri(&location).unwrap(); + } + } + + pub fn add_file(&mut self, target_file: &Path) -> Result<(), Box> { + let new_song = Song::from_file(target_file)?; + match self.add_song(new_song) { + Ok(_) => (), + Err(_) => { + //return Err(error) + } + }; + + Ok(()) + } + + pub fn add_cuesheet(&mut self, cuesheet: &Path) -> Result> { + let tracks = Song::from_cue(cuesheet)?; + let mut tracks_added = tracks.len() as i32; + + for (new_song, location) in tracks { + // Try to remove the original audio file from the db if it exists + if self.remove_uri(&URI::Local(location.clone())).is_ok() { + tracks_added -= 1 + } + match self.add_song(new_song) { + Ok(_) => {} + Err(_error) => { + //println!("{}", _error); + continue; + } + }; + } + Ok(tracks_added) + } + + pub fn add_song(&mut self, new_song: Song) -> Result<(), Box> { + let location = new_song.primary_uri()?.0; + if self.query_uri(location).is_some() { + return Err(format!("URI already in database: {:?}", location).into()); + } + + match location { + URI::Local(_) if self.query_path(location.path()).is_some() => { + return Err(format!("Location exists for {:?}", location).into()) + } + _ => (), + } + + self.library.push(new_song); + + Ok(()) + } + + /// Removes a song indexed by URI, returning the position removed + pub fn remove_uri(&mut self, target_uri: &URI) -> Result> { + let location = match self.query_uri(target_uri) { + Some(value) => value.1, + None => return Err("URI not in database".into()), + }; + + self.library.remove(location); + + Ok(location) + } + + /// Scan the song by a location and update its tags + // TODO: change this to work with multiple uris + pub fn update_uri( + &mut self, + target_uri: &URI, + new_tags: Vec, + ) -> Result<(), Box> { + let (target_song, _) = match self.query_uri(target_uri) { + Some(song) => song, + None => return Err("URI not in database!".to_string().into()), + }; + + println!("{:?}", target_song.location); + + for tag in new_tags { + println!("{:?}", tag); + } + + todo!() + } + + /// Query the database, returning a list of [Song]s + /// + /// The order in which the `sort by` Vec is arranged + /// determines the output sorting. + /// + /// Example: + /// ``` + /// use dango_core::music_storage::music_db::Tag; + /// query_tracks( + /// &String::from("query"), + /// &vec![ + /// Tag::Title + /// ], + /// &vec![ + /// Tag::Field("location".to_string()), + /// Tag::Album, + /// Tag::Disk, + /// Tag::Track, + /// ], + /// ) + /// ``` + /// This would find all titles containing the sequence + /// "query", and would return the results sorted first + /// by path, then album, disk number, and finally track number. + pub fn query_tracks( + &self, + query_string: &String, // The query itself + target_tags: &Vec, // The tags to search + sort_by: &Vec, // Tags to sort the resulting data by + ) -> Option> { + let songs = Arc::new(Mutex::new(Vec::new())); + //let matcher = SkimMatcherV2::default(); + + self.library.par_iter().for_each(|track| { + for tag in target_tags { + let track_result = match tag { + Tag::Field(target) => match track.get_field(target) { + Some(value) => value.to_string(), + None => continue, + }, + _ => match track.get_tag(tag) { + Some(value) => value.clone(), + None => continue, + }, + }; + + /* + let match_level = match matcher.fuzzy_match(&normalize(&track_result), &normalize(query_string)) { + Some(conf) => conf, + None => continue + }; + + if match_level > 100 { + songs.lock().unwrap().push(track); + return; + } + */ + + if normalize(&track_result.to_string()) + .contains(&normalize(&query_string.to_owned())) + { + songs.lock().unwrap().push(track); + return; + } + } + }); + + let lock = Arc::try_unwrap(songs).expect("Lock still has multiple owners!"); + let mut new_songs = lock.into_inner().expect("Mutex cannot be locked!"); + + // Sort the returned list of songs + new_songs.par_sort_by(|a, b| { + for sort_option in sort_by { + let tag_a = match sort_option { + Tag::Field(field_selection) => match a.get_field(field_selection) { + Some(field_value) => field_value.to_string(), + None => continue, + }, + _ => match a.get_tag(sort_option) { + Some(tag_value) => tag_value.to_owned(), + None => continue, + }, + }; + + let tag_b = match sort_option { + Tag::Field(field_selection) => match b.get_field(field_selection) { + Some(field_value) => field_value.to_string(), + None => continue, + }, + _ => match b.get_tag(sort_option) { + Some(tag_value) => tag_value.to_owned(), + None => continue, + }, + }; + + if let (Ok(num_a), Ok(num_b)) = (tag_a.parse::(), tag_b.parse::()) { + // If parsing succeeds, compare as numbers + return num_a.cmp(&num_b); + } else { + // If parsing fails, compare as strings + return tag_a.cmp(&tag_b); + } + } + + // If all tags are equal, sort by Track number + let path_a = PathBuf::from(a.get_field("location").unwrap().to_string()); + let path_b = PathBuf::from(b.get_field("location").unwrap().to_string()); + + path_a.file_name().cmp(&path_b.file_name()) + }); + + if !new_songs.is_empty() { + Some(new_songs) + } else { + None + } + } + + /// Generates all albums from the track list + pub fn albums(&self) -> BTreeMap { + let mut paths = BTreeMap::new(); + + let mut albums: BTreeMap = BTreeMap::new(); + for song in &self.library { + let album_title = match song.get_tag(&Tag::Album) { + Some(title) => title.clone(), + None => continue, + }; + //let norm_title = normalize(&album_title); + + let disc_num = song + .get_tag(&Tag::Disk) + .unwrap_or(&"".to_string()) + .parse::() + .unwrap_or(1); + + match albums.get_mut(&album_title) { + // If the album is in the list, add the track to the appropriate disc within the album + Some(album) => match album.discs.get_mut(&disc_num) { + Some(disc) => disc.push(( + song.get_tag(&Tag::Track) + .unwrap_or(&String::new()) + .parse::() + .unwrap_or_default(), + song.uuid, + )), + None => { + album.discs.insert( + disc_num, + vec![( + song.get_tag(&Tag::Track) + .unwrap_or(&String::new()) + .parse::() + .unwrap_or_default(), + song.uuid, + )], + ); + } + }, + // If the album is not in the list, make it new one and add it + None => { + let album_art = song.album_art.first(); + let new_album = Album { + title: album_title.clone(), + artist: song.get_tag(&Tag::AlbumArtist).cloned(), + discs: BTreeMap::from([( + disc_num, + vec![( + song.get_tag(&Tag::Track) + .unwrap_or(&String::new()) + .parse::() + .unwrap_or_default(), + song.uuid, + )], + )]), + cover: album_art.cloned(), + }; + albums.insert(album_title, new_album); + } + } + paths.insert(song.uuid, song.primary_uri().unwrap()); + } + + // Sort the tracks in each disk in each album + albums.par_iter_mut().for_each(|album| { + for disc in &mut album.1.discs { + disc.1.sort_by(|a, b| { + let num_a = a.0; + let num_b = b.0; + + if (num_a, num_b) != (0, 0) { + // If parsing the track numbers succeeds, compare as numbers + num_a.cmp(&num_b) + } else { + // If parsing doesn't succeed, compare the locations + let a = match paths.get_key_value(&a.1) { + Some((_, (uri, _))) => uri, + None => return Ordering::Equal, + }; + let b = match paths.get_key_value(&b.1) { + Some((_, (uri, _))) => uri, + None => return Ordering::Equal, + }; + + a.as_uri().cmp(&b.as_uri()) + } + }); + } + }); + + // Return the albums! + albums + } + + /// Queries a list of albums by title + pub fn query_albums( + &self, + query_string: &str, // The query itself + ) -> Result, Box> { + let all_albums = self.albums(); + + let normalized_query = normalize(query_string); + let albums: Vec = all_albums + .par_iter() + .filter_map(|album| { + if normalize(album.0).contains(&normalized_query) { + Some(album.1.clone()) + } else { + None + } + }) + .collect(); + + Ok(albums) + } +} + +#[cfg(test)] +mod test { + use std::{ + path::PathBuf, + sync::{Arc, RwLock}, + }; + + use crate::{ + config::{tests::new_config_lib, Config}, + music_storage::library::MusicLibrary, + }; + + #[test] + fn library_init() { + let config = Config::read_file(PathBuf::from("test_config/config_test.json")).unwrap(); + let target_uuid = config.libraries.libraries[0].uuid; + let a = MusicLibrary::init( + config.libraries.get_default().unwrap().path.clone(), + target_uuid, + ) + .unwrap(); + dbg!(a); + } +} diff --git a/dmp-core/src/music_storage/music_collection.rs b/dmp-core/src/music_storage/music_collection.rs new file mode 100644 index 0000000..964c79a --- /dev/null +++ b/dmp-core/src/music_storage/music_collection.rs @@ -0,0 +1,7 @@ +use crate::music_storage::library::{AlbumArt, Song}; + +pub trait MusicCollection { + fn title(&self) -> &String; + fn cover(&self) -> Option<&AlbumArt>; + fn tracks(&self) -> Vec; +} diff --git a/dmp-core/src/music_storage/playlist.rs b/dmp-core/src/music_storage/playlist.rs new file mode 100644 index 0000000..c4c0d8c --- /dev/null +++ b/dmp-core/src/music_storage/playlist.rs @@ -0,0 +1,353 @@ +use std::error::Error; +use std::{ + fs::File, + io::Read, + path::PathBuf, + sync::{Arc, RwLock}, +}; + +use std::time::Duration; + +// use chrono::Duration; +use super::library::{AlbumArt, MusicLibrary, Song, Tag, URI}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use m3u8_rs::{MediaPlaylist, MediaPlaylistType, MediaSegment, Playlist as List2}; +use nestify::nest; + +use rayon::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SortOrder { + Manual, + Tag(Vec), +} + +nest! { + #[derive(Debug, Clone, Deserialize, Serialize)]* + #[derive(Default)] + pub struct PlaylistFolder { + name: String, + items: Vec< + pub enum PlaylistFolderItem { + Folder(PlaylistFolder), + List(Playlist) + } + > + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Playlist { + uuid: Uuid, + title: String, + cover: Option, + tracks: Vec, + sort_order: SortOrder, + play_count: i32, + play_time: Duration, +} +impl Playlist { + pub fn new() -> Self { + Default::default() + } + pub fn play_count(&self) -> i32 { + self.play_count + } + pub fn play_time(&self) -> Duration { + self.play_time + } + + pub fn title(&self) -> &String { + &self.title + } + + pub fn cover(&self) -> Option<&AlbumArt> { + match &self.cover { + Some(e) => Some(e), + None => None, + } + } + + pub fn tracks(&self) -> Vec { + self.tracks.to_owned() + } + + pub fn set_tracks(&mut self, tracks: Vec) { + self.tracks = tracks; + } + + pub fn add_track(&mut self, track: Uuid) { + self.tracks.push(track); + } + + pub fn remove_track(&mut self, index: i32) { + let index = index as usize; + if (self.tracks.len() - 1) >= index { + self.tracks.remove(index); + } + } + pub fn get_index(&self, uuid: Uuid) -> Option { + let mut i = 0; + if self.contains(uuid) { + for track in &self.tracks { + i += 1; + if &uuid == track { + dbg!("Index gotted! ", i); + return Some(i); + } + } + } + None + } + pub fn contains(&self, uuid: Uuid) -> bool { + self.get_index(uuid).is_some() + } + + pub fn to_file(&self, path: &str) -> Result<(), Box> { + super::utils::write_file(self, PathBuf::from(path))?; + Ok(()) + } + + pub fn from_file(path: &str) -> Result> { + super::utils::read_file(PathBuf::from(path)) + } + + pub fn to_m3u8( + &mut self, + lib: Arc>, + location: &str, + ) -> Result<(), Box> { + let lib = lib.read().unwrap(); + let seg = self + .tracks + .iter() + .filter_map(|uuid| { + // TODO: The Unwraps need to be handled here + if let Some((track, _)) = lib.query_uuid(uuid) { + if let URI::Local(_) = track.primary_uri().unwrap().0 { + Some(MediaSegment { + uri: track.primary_uri().unwrap().0.to_string(), + duration: track.duration.as_millis() as f32, + title: track + .tags + .get_key_value(&Tag::Title) + .map(|tag| tag.1.into()), + ..Default::default() + }) + } else { + None + } + } else { + None + } + }) + .collect::>(); + + let m3u8 = MediaPlaylist { + version: Some(6), + target_duration: 3.0, + media_sequence: 338559, + discontinuity_sequence: 1234, + end_list: true, + playlist_type: Some(MediaPlaylistType::Vod), + segments: seg.clone(), + ..Default::default() + }; + + let mut file = std::fs::OpenOptions::new() + .read(true) + .create(true) + .truncate(true) + .write(true) + .open(location)?; + m3u8.write_to(&mut file)?; + Ok(()) + } + + pub fn from_m3u8( + path: &str, + lib: Arc>, + ) -> Result> { + let mut file = match File::open(path) { + Ok(file) => file, + Err(e) => return Err(e.into()), + }; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + + let parsed = m3u8_rs::parse_playlist(&bytes); + + let playlist = match parsed { + Result::Ok((_, playlist)) => playlist, + Result::Err(e) => panic!("Parsing error: \n{}", e), + }; + + match playlist { + List2::MasterPlaylist(_) => { + Err("This is a Master Playlist!\nPlase input a Media Playlist".into()) + } + List2::MediaPlaylist(playlist_) => { + let mut uuids = Vec::new(); + for seg in playlist_.segments { + let path_ = PathBuf::from(seg.uri.to_owned()); + let mut lib = lib.write().unwrap(); + + let uuid = if let Some((song, _)) = lib.query_uri(&URI::Local(path_.clone())) { + song.uuid + } else { + let song_ = Song::from_file(&path_)?; + let uuid = song_.uuid.to_owned(); + lib.add_song(song_)?; + uuid + }; + uuids.push(uuid); + } + let mut playlist = Playlist::new(); + + #[cfg(target_family = "windows")] + { + playlist.title = path + .split('\\') + .last() + .unwrap_or_default() + .strip_suffix(".m3u8") + .unwrap_or_default() + .to_string(); + } + #[cfg(target_family = "unix")] + { + playlist.title = path + .split("/") + .last() + .unwrap_or_default() + .strip_suffix(".m3u8") + .unwrap_or_default() + .to_string(); + } + + playlist.set_tracks(uuids); + Ok(playlist) + } + } + } + + pub fn out_tracks(&self, lib: Arc>) -> (Vec, Vec<&Uuid>) { + let lib = lib.read().unwrap(); + let mut songs = vec![]; + let mut invalid_uuids = vec![]; + + for uuid in &self.tracks { + if let Some((track, _)) = lib.query_uuid(uuid) { + songs.push(track.to_owned()); + } else { + invalid_uuids.push(uuid); + } + } + + if let SortOrder::Tag(sort_by) = &self.sort_order { + println!("sorting by: {:?}", sort_by); + + songs.par_sort_by(|a, b| { + for (i, sort_option) in sort_by.iter().enumerate() { + dbg!(&i); + let tag_a = match sort_option { + Tag::Field(field_selection) => { + match a.get_field(field_selection.as_str()) { + Some(field_value) => field_value.to_string(), + None => continue, + } + } + _ => match a.get_tag(sort_option) { + Some(tag_value) => tag_value.to_owned(), + None => continue, + }, + }; + + let tag_b = match sort_option { + Tag::Field(field_selection) => match b.get_field(field_selection) { + Some(field_value) => field_value.to_string(), + None => continue, + }, + _ => match b.get_tag(sort_option) { + Some(tag_value) => tag_value.to_owned(), + None => continue, + }, + }; + dbg!(&i); + + if let (Ok(num_a), Ok(num_b)) = (tag_a.parse::(), tag_b.parse::()) { + // If parsing succeeds, compare as numbers + return dbg!(num_a.cmp(&num_b)); + } else { + // If parsing fails, compare as strings + return dbg!(tag_a.cmp(&tag_b)); + } + } + + // If all tags are equal, sort by Track number + let path_a = PathBuf::from(a.get_field("location").unwrap().to_string()); + let path_b = PathBuf::from(b.get_field("location").unwrap().to_string()); + + path_a.file_name().cmp(&path_b.file_name()) + }) + } + + (songs, invalid_uuids) + } +} + +impl Default for Playlist { + fn default() -> Self { + Playlist { + uuid: Uuid::new_v4(), + title: String::default(), + cover: None, + tracks: Vec::default(), + sort_order: SortOrder::Manual, + play_count: 0, + play_time: Duration::from_secs(0), + } + } +} + +#[cfg(test)] +mod test_super { + use super::*; + use crate::config::tests::read_config_lib; + + #[test] + fn list_to_m3u8() { + let (_, lib) = read_config_lib(); + let mut playlist = Playlist::new(); + let tracks = lib.library.iter().map(|track| track.uuid).collect(); + playlist.set_tracks(tracks); + + _ = playlist.to_m3u8( + Arc::new(RwLock::from(lib)), + ".\\test-config\\playlists\\playlist.m3u8", + ); + } + + fn m3u8_to_list() -> Playlist { + let (_, lib) = read_config_lib(); + let arc = Arc::new(RwLock::from(lib)); + let playlist = + Playlist::from_m3u8(".\\test-config\\playlists\\playlist.m3u8", arc).unwrap(); + + _ = playlist.to_file(".\\test-config\\playlists\\playlist"); + dbg!(playlist) + } + + #[test] + fn out_queue_sort() { + let (_, lib) = read_config_lib(); + let mut list = m3u8_to_list(); + list.sort_order = SortOrder::Tag(vec![Tag::Album]); + + let songs = &list.out_tracks(Arc::new(RwLock::from(lib))); + + dbg!(songs); + } +} diff --git a/dmp-core/src/music_storage/utils.rs b/dmp-core/src/music_storage/utils.rs new file mode 100644 index 0000000..991d3d7 --- /dev/null +++ b/dmp-core/src/music_storage/utils.rs @@ -0,0 +1,97 @@ +use ciborium::{from_reader, into_writer}; +use deunicode::deunicode_with_tofu; +use file_format::{FileFormat, Kind}; +use std::error::Error; +use std::fs::{self, File}; +use std::io::{BufReader, BufWriter}; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +use super::library::{AlbumArt, URI}; + +#[cfg(target_family = "windows")] +use std::os::windows::fs::MetadataExt; + +pub(super) fn normalize(input_string: &str) -> String { + // Normalize the string to latin characters... this needs a lot of work + let mut normalized = deunicode_with_tofu(input_string, " "); + + // Remove non alphanumeric characters + normalized.retain(|c| c.is_alphanumeric()); + normalized = normalized.to_ascii_lowercase(); + + normalized +} + +/// Write any data structure which implements [serde::Serialize] +/// out to a [cbor] encoded file compressed using [ciborium] +pub(super) fn write_file< + T: serde::Serialize, + U: std::convert::AsRef + std::convert::AsRef + Clone, +>( + library: T, + path: U, +) -> Result<(), Box> { + // Create a temporary name for writing out + let mut writer_name = PathBuf::from(&path); + writer_name.set_extension("tmp"); + + // Create a new BufWriter on the file and a snap frame encoder + let writer = BufWriter::new(File::create(&writer_name)?); + //let mut e = snap::write::FrameEncoder::new(writer); + + // Write out the data + into_writer(&library, writer)?; + fs::rename(writer_name, &path)?; + + Ok(()) +} + +/// Read a file serialized out with [write_file] and turn it into +/// the desired structure +pub(super) fn read_file serde::Deserialize<'de>>( + path: PathBuf, +) -> Result> { + // Create a new snap reader over the file + let file_reader = BufReader::new(File::open(path)?); + //let mut d = snap::read::FrameDecoder::new(file_reader); + + // Decode the library from the serialized data into the vec + let library: T = from_reader(file_reader)?; + + Ok(library) +} + +pub fn find_images(song_path: &Path) -> Result, Box> { + let mut images: Vec = Vec::new(); + + let song_dir = song_path.parent().ok_or("")?; + for target_file in WalkDir::new(song_dir) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.depth() < 3) + // Don't recurse very deep + { + let path = target_file.path(); + if !path.is_file() || !path.exists() { + continue; + } + + let format = FileFormat::from_file(path)?.kind(); + if format != Kind::Image { + continue; + } + + #[cfg(target_family = "windows")] + if (4 & path.metadata().unwrap().file_attributes()) == 4 { + continue; + } + + let image_uri = URI::Local(path.to_path_buf().canonicalize()?); + + images.push(AlbumArt::External(image_uri)); + } + + Ok(images) +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b642688..ee5ea94 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,7 +6,8 @@ "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run build" + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" }, "app": { "windows": [