diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..117990b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+*.log
+.DS_Store
+.env
+*.csv
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENCE
@@ -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/README.md b/README.md
new file mode 100644
index 0000000..05b6079
--- /dev/null
+++ b/README.md
@@ -0,0 +1,109 @@
+# Last.fm to ATProto Importer
+
+Import your Last.fm listening history to the AT Protocol network using the `fm.teal.alpha.feed.play` lexicon.
+
+## Setup
+
+```bash
+npm install
+```
+
+## Usage
+
+### Interactive Mode
+
+```bash
+node importer.js
+```
+
+### With Command Line Arguments
+
+**Full automation:**
+
+```bash
+node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx -y
+```
+
+**Dry run (preview without publishing):**
+
+```bash
+node importer.js -f lastfm.csv --dry-run
+```
+
+**Custom batch settings:**
+
+```bash
+node importer.js -f lastfm.csv -i alice.bsky.social -b 20 -d 3000
+```
+
+## Options
+
+- `-h, --help` - Show help message
+- `-f, --file ` - Path to Last.fm CSV export file
+- `-i, --identifier ` - ATProto handle or DID
+- `-p, --password ` - ATProto app password
+- `-b, --batch-size ` - Records per batch (default: 10)
+- `-d, --batch-delay ` - Delay between batches in ms (default: 2000)
+- `-y, --yes` - Skip confirmation prompt
+- `-n, --dry-run` - Preview records without publishing
+
+## Getting Your Last.fm Data
+
+1. Go to
+2. Request your data export in CSV
+3. Download the CSV file when ready
+4. Use the CSV file path with this script
+
+## Features
+
+- ✅ Resolves ATProto handles/DIDs using Slingshot
+- ✅ Connects to your personal PDS
+- ✅ Converts Last.fm scrobbles to `fm.teal.alpha.feed.play` records
+- ✅ Follows the official lexicon schema
+- ✅ Batch publishing with configurable rate limiting
+- ✅ Dry run mode for previewing
+- ✅ Progress tracking and error reporting
+- ✅ Preserves MusicBrainz IDs when available
+
+## Record Format
+
+Each scrobble is converted according to the `fm.teal.alpha.feed.play` lexicon:
+
+```json
+{
+ "$type": "fm.teal.alpha.feed.play",
+ "trackName": "Paint My Masterpiece",
+ "artists": [
+ {
+ "artistName": "Cjbeards",
+ "artistMbId": "c8d4f4bf-1b82-4d4d-9d73-05909faaff89"
+ }
+ ],
+ "releaseName": "Masquerade",
+ "releaseMbId": "fdb2397b-78d5-4019-8fad-656d286e4d33",
+ "recordingMbId": "3a390ad3-fe56-45f2-a073-bebc45d6bde1",
+ "playedTime": "2025-11-13T23:49:36Z",
+ "originUrl": "https://www.last.fm/music/Cjbeards/_/Paint+My+Masterpiece",
+ "submissionClientAgent": "lastfm-importer/v1.0.0",
+ "musicServiceBaseDomain": "last.fm"
+}
+```
+
+### Required Fields
+
+- `trackName` - The name of the track
+- `artists` - Array of artist objects with `artistName` (required) and optional `artistMbId`
+
+### Optional Fields
+
+- `releaseName` - Album name
+- `releaseMbId` - MusicBrainz release ID
+- `recordingMbId` - MusicBrainz recording ID
+- `playedTime` - ISO 8601 datetime
+- `originUrl` - Link to the track
+- `submissionClientAgent` - Client identifier
+- `musicServiceBaseDomain` - Service domain (e.g., "last.fm")
+
+## Lexicon Reference
+
+This importer follows the lexicon defined in `/lexicons/fm.teal.alpha/feed/play.json`.
diff --git a/STRUCTURE.md b/STRUCTURE.md
new file mode 100644
index 0000000..eebbc13
--- /dev/null
+++ b/STRUCTURE.md
@@ -0,0 +1,126 @@
+# Last.fm to ATProto Importer - Modular Structure
+
+## Project Structure
+
+```plaintext
+lastfm-importer/
+├── src/
+│ ├── index.js # Main entry point
+│ ├── config.js # Configuration constants
+│ ├── lib/ # Core library modules
+│ │ ├── auth.js # Authentication & login
+│ │ ├── cli.js # CLI argument parsing & help
+│ │ ├── csv.js # CSV parsing & conversion
+│ │ └── publisher.js # Record publishing logic
+│ └── utils/ # Utility functions
+│ ├── helpers.js # Helper functions (formatting, batch calculation)
+│ ├── input.js # User input & password masking
+│ └── killswitch.js # Graceful shutdown handling
+├── importer.js # Wrapper for backwards compatibility
+└── importer.old.js # Original monolithic version (backup)
+```
+
+## Module Responsibilities
+
+### `/src/config.js`
+
+- Configuration constants
+- Batch size calculation parameters
+- API endpoints and client information
+
+### `/src/lib/auth.js`
+
+- ATProto authentication
+- Identity resolution via Slingshot
+- Login error handling
+
+### `/src/lib/cli.js`
+
+- Command-line argument parsing
+- Help text display
+- Input validation
+
+### `/src/lib/csv.js`
+
+- CSV file parsing
+- Record conversion to ATProto format
+- Chronological sorting
+
+### `/src/lib/publisher.js`
+
+- Batch publishing with rate limiting
+- Dry-run preview mode
+- Progress tracking and reporting
+- Killswitch integration
+
+### `/src/utils/helpers.js`
+
+- Duration formatting
+- Optimal batch size calculation (logarithmic algorithm)
+- Generic utility functions
+
+### `/src/utils/input.js`
+
+- Interactive prompts
+- Password masking with asterisks
+- Backspace support
+
+### `/src/utils/killswitch.js`
+
+- SIGINT handler
+- Graceful shutdown state management
+- Force-quit on second Ctrl+C
+
+## Benefits of Modular Structure
+
+1. **Maintainability**: Each module has a single responsibility
+2. **Testability**: Individual modules can be tested in isolation
+3. **Reusability**: Modules can be imported and reused
+4. **Readability**: Smaller files are easier to understand
+5. **Collaboration**: Multiple developers can work on different modules
+6. **Debugging**: Easier to locate and fix issues
+
+## Usage
+
+The wrapper file (`importer.js`) maintains backwards compatibility:
+
+```bash
+# Still works exactly as before
+node importer.js -f lastfm.csv -i handle.bsky.social
+
+# Or use the modular version directly
+node src/index.js -f lastfm.csv -i handle.bsky.social
+```
+
+## Algorithm Details
+
+### Batch Size Calculation
+
+Located in `/src/utils/helpers.js`:
+
+```javascript
+batchSize = BASE + (log2(records/MIN) * SCALING_FACTOR)
+```
+
+- **Time Complexity**: O(n) - each record processed once
+- **Space Complexity**: O(b) where b is batch size
+- **Rate Limit Strategy**: Token bucket approach
+- **Adaptive**: Adjusts based on total records and delay settings
+
+### Processing Order
+
+- Default: Chronological (oldest first)
+- Option: `--reverse-chronological` for newest first
+- Sorted by `playedTime` field
+
+## Future Improvements
+
+With the modular structure, it's now easier to:
+
+- Add unit tests for each module
+- Implement different authentication methods
+- Support multiple export formats (JSON, XML)
+- Add progress persistence (resume interrupted imports)
+- Implement retry logic with exponential backoff
+- Add statistics and analytics
+- Create a web UI that imports these modules
diff --git a/importer.js b/importer.js
new file mode 100644
index 0000000..921093d
--- /dev/null
+++ b/importer.js
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+
+// Wrapper file for backwards compatibility
+// This imports and runs the modular version
+import './src/index.js';
diff --git a/importer.old.js b/importer.old.js
new file mode 100644
index 0000000..7cbf73e
--- /dev/null
+++ b/importer.old.js
@@ -0,0 +1,681 @@
+#!/usr/bin/env node
+
+import { AtpAgent } from '@atproto/api';
+import * as fs from 'fs';
+import * as readline from 'readline';
+import { parse } from 'csv-parse/sync';
+import { parseArgs } from 'node:util';
+
+// Configuration
+const DEFAULT_BATCH_SIZE = 10; // Default number of records to submit per batch
+const DEFAULT_BATCH_DELAY = 2000; // Default delay between batches in milliseconds
+const MIN_BATCH_DELAY = 1000; // Minimum safe delay to respect rate limits
+const RECORD_TYPE = 'fm.teal.alpha.feed.play';
+const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc';
+
+// Global state for killswitch
+let importCancelled = false;
+let gracefulShutdown = false;
+
+/**
+ * Setup killswitch handler for graceful shutdown
+ */
+function setupKillswitch() {
+ process.on('SIGINT', () => {
+ if (gracefulShutdown) {
+ console.log('\n\n⚠️ Force quit detected. Exiting immediately...');
+ process.exit(1);
+ }
+
+ gracefulShutdown = true;
+ importCancelled = true;
+ console.log('\n\n🛑 Killswitch activated! Stopping after current batch...');
+ console.log(' Press Ctrl+C again to force quit immediately.\n');
+ });
+}
+
+/**
+ * Calculate optimal batch size based on total records and rate limits
+ * Uses a logarithmic scaling approach to balance throughput with API safety
+ *
+ * Algorithm Analysis:
+ * - Time Complexity: O(n) where n is total records (each record processed once)
+ * - Space Complexity: O(1) for batch calculation, O(b) where b is batch size in memory
+ * - Rate Limit Strategy: Token bucket approach with conservative limits
+ *
+ * The batch size grows logarithmically with input size to prevent overwhelming
+ * the API while maximizing throughput. Formula: min(MAX, BASE * log2(n/MIN))
+ */
+function calculateOptimalBatchSize(totalRecords, batchDelay = DEFAULT_BATCH_DELAY) {
+ // Constants based on typical API rate limits and safety margins
+ const MIN_RECORDS = 100; // Minimum records before scaling kicks in
+ const BASE_BATCH_SIZE = 5; // Starting point for small datasets
+ const MAX_BATCH_SIZE = 50; // Hard cap to prevent API overwhelming
+ const SCALING_FACTOR = 1.5; // Growth rate modifier
+
+ // For very small datasets, use minimal batches
+ if (totalRecords <= 50) {
+ return 3;
+ }
+
+ // For small to medium datasets, use conservative batching
+ if (totalRecords <= MIN_RECORDS) {
+ return BASE_BATCH_SIZE;
+ }
+
+ // Logarithmic scaling: batch size grows with log of total records
+ // This ensures O(n) time complexity while respecting rate limits
+ // Formula: BASE * (log2(n/MIN) * SCALING_FACTOR)
+ const logScale = Math.log2(totalRecords / MIN_RECORDS);
+ const calculatedSize = Math.floor(BASE_BATCH_SIZE + (logScale * SCALING_FACTOR));
+
+ // Apply maximum cap and ensure reasonable batch size
+ let optimalSize = Math.min(calculatedSize, MAX_BATCH_SIZE);
+
+ // Adjust based on batch delay to respect rate limits
+ // Shorter delays should use smaller batches
+ if (batchDelay < 1500 && optimalSize > 15) {
+ optimalSize = Math.floor(optimalSize * 0.75);
+ }
+
+ // Ensure batch size is at least 3 for efficiency
+ return Math.max(3, optimalSize);
+}
+
+/**
+ * Parse command line arguments
+ */
+function parseCommandLineArgs() {
+ const options = {
+ help: {
+ type: 'boolean',
+ short: 'h',
+ default: false,
+ },
+ file: {
+ type: 'string',
+ short: 'f',
+ },
+ identifier: {
+ type: 'string',
+ short: 'i',
+ },
+ password: {
+ type: 'string',
+ short: 'p',
+ },
+ 'batch-size': {
+ type: 'string',
+ short: 'b',
+ },
+ 'batch-delay': {
+ type: 'string',
+ short: 'd',
+ },
+ yes: {
+ type: 'boolean',
+ short: 'y',
+ default: false,
+ },
+ 'dry-run': {
+ type: 'boolean',
+ short: 'n',
+ default: false,
+ },
+ 'reverse-chronological': {
+ type: 'boolean',
+ short: 'r',
+ default: false,
+ },
+ };
+
+ try {
+ const { values } = parseArgs({ options, allowPositionals: false });
+ return values;
+ } catch (error) {
+ console.error('Error parsing arguments:', error.message);
+ showHelp();
+ process.exit(1);
+ }
+}
+
+/**
+ * Show help message
+ */
+function showHelp() {
+ console.log(`
+Last.fm to ATProto Importer
+
+Usage: node importer.js [options]
+
+Options:
+ -h, --help Show this help message
+ -f, --file Path to Last.fm CSV export file
+ -i, --identifier ATProto handle or DID
+ -p, --password ATProto app password
+ -b, --batch-size Number of records per batch (auto-calculated if not set)
+ -d, --batch-delay Delay between batches in ms (default: 2000, min: 1000)
+ -y, --yes Skip confirmation prompt
+ -n, --dry-run Preview records without publishing
+ -r, --reverse-chronological Process newest first (default: oldest first)
+
+Examples:
+ node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx
+ node importer.js --file export.csv --identifier alice.bsky.social --yes
+ node importer.js -f lastfm.csv --dry-run
+ node importer.js (interactive mode - prompts for all values)
+
+Notes:
+ - Batch size uses logarithmic scaling algorithm (O(n) complexity) for optimal throughput
+ - Auto-calculated batch size considers both record count and delay settings
+ - Records are processed in chronological order (oldest first) by default
+ - Minimum batch delay of 1000ms enforced to respect rate limits
+ - Rate limiting follows token bucket strategy for safe API usage
+`);
+}
+
+/**
+ * Read user input from command line with proper password masking
+ */
+function prompt(question, hideInput = false) {
+ return new Promise((resolve) => {
+ if (hideInput) {
+ // For password input, use a simpler approach
+ const stdin = process.stdin;
+ const wasRaw = stdin.isRaw;
+
+ // Set raw mode to capture individual keystrokes
+ if (stdin.isTTY) {
+ stdin.setRawMode(true);
+ }
+
+ stdin.resume();
+ stdin.setEncoding('utf8');
+
+ process.stdout.write(question);
+
+ let password = '';
+ const onData = (char) => {
+ char = char.toString();
+
+ switch (char) {
+ case '\n':
+ case '\r':
+ case '\u0004': // Ctrl-D
+ stdin.removeListener('data', onData);
+ if (stdin.isTTY) {
+ stdin.setRawMode(wasRaw);
+ }
+ stdin.pause();
+ process.stdout.write('\n');
+ resolve(password);
+ break;
+ case '\u0003': // Ctrl-C
+ process.exit(1);
+ break;
+ case '\u007f': // Backspace
+ case '\b': // Backspace
+ if (password.length > 0) {
+ password = password.slice(0, -1);
+ process.stdout.clearLine(0);
+ process.stdout.cursorTo(0);
+ process.stdout.write(question + '*'.repeat(password.length));
+ }
+ break;
+ default:
+ password += char;
+ process.stdout.write('*');
+ break;
+ }
+ };
+
+ stdin.on('data', onData);
+ } else {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer);
+ });
+ }
+ });
+}
+
+/**
+ * Resolves an AT Protocol identifier (handle or DID) to get PDS information
+ */
+async function resolveIdentifier(identifier) {
+ console.log(`Resolving identifier: ${identifier}`);
+
+ const response = await fetch(
+ `${SLINGSHOT_RESOLVER}?identifier=${encodeURIComponent(identifier)}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ if (!data.did || !data.pds) {
+ throw new Error('Invalid response from identity resolver');
+ }
+
+ console.log(`✓ Resolved to PDS: ${data.pds}`);
+ return data;
+}
+
+/**
+ * Login to ATProto using Slingshot resolver
+ */
+async function login(identifier, password) {
+ console.log('\n=== ATProto Login ===');
+
+ // Prompt for missing credentials
+ if (!identifier) {
+ identifier = await prompt('Handle or DID: ');
+ } else {
+ console.log(`Handle or DID: ${identifier}`);
+ }
+
+ if (!password) {
+ password = await prompt('App password: ', true);
+ } else {
+ console.log('App password: [hidden]');
+ }
+
+ try {
+ // Resolve the identifier to get PDS
+ const resolved = await resolveIdentifier(identifier);
+
+ // Create agent with resolved PDS
+ const pdsAgent = new AtpAgent({ service: resolved.pds });
+
+ // Login using the resolved DID
+ await pdsAgent.login({
+ identifier: resolved.did,
+ password: password,
+ });
+
+ console.log('✓ Logged in successfully!');
+ console.log(` DID: ${pdsAgent.session.did}`);
+ console.log(` Handle: ${pdsAgent.session.handle}\n`);
+
+ return pdsAgent;
+ } catch (error) {
+ console.error('✗ Login failed:', error.message);
+
+ // Provide more specific error messages
+ if (error.message.includes('Failed to resolve identifier')) {
+ throw new Error('Handle not found. Please check your AT Protocol handle.');
+ } else if (error.message.includes('AuthFactorTokenRequired')) {
+ throw new Error('Two-factor authentication required. Please use your app password.');
+ } else if (error.message.includes('InvalidCredentials')) {
+ throw new Error('Invalid credentials. Please check your handle and app password.');
+ }
+
+ throw error;
+ }
+}
+
+/**
+ * Parse Last.fm CSV export
+ */
+function parseLastFmCsv(filePath) {
+ console.log(`Reading CSV file: ${filePath}`);
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
+
+ const records = parse(fileContent, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+
+ console.log(`✓ Parsed ${records.length} scrobbles\n`);
+ return records;
+}
+
+/**
+ * Convert Last.fm CSV record to ATProto play record
+ * Following the fm.teal.alpha.feed.play lexicon schema
+ */
+function convertToPlayRecord(csvRecord) {
+ // Parse the timestamp (Unix timestamp in seconds)
+ const timestamp = parseInt(csvRecord.uts);
+ const playedTime = new Date(timestamp * 1000).toISOString();
+
+ // Build artists array according to lexicon
+ const artists = [];
+ if (csvRecord.artist) {
+ const artistData = {
+ artistName: csvRecord.artist,
+ };
+ // Only add artistMbId if it exists and is not empty
+ if (csvRecord.artist_mbid && csvRecord.artist_mbid.trim()) {
+ artistData.artistMbId = csvRecord.artist_mbid;
+ }
+ artists.push(artistData);
+ }
+
+ // Build the play record with required fields
+ const playRecord = {
+ $type: RECORD_TYPE,
+ trackName: csvRecord.track,
+ artists, // Required field
+ playedTime,
+ submissionClientAgent: 'lastfm-importer/v0.0.1',
+ musicServiceBaseDomain: 'last.fm',
+ };
+
+ // Add optional fields only if present and not empty
+ if (csvRecord.album && csvRecord.album.trim()) {
+ playRecord.releaseName = csvRecord.album;
+ }
+
+ if (csvRecord.album_mbid && csvRecord.album_mbid.trim()) {
+ playRecord.releaseMbId = csvRecord.album_mbid;
+ }
+
+ if (csvRecord.track_mbid && csvRecord.track_mbid.trim()) {
+ playRecord.recordingMbId = csvRecord.track_mbid;
+ }
+
+ // Generate Last.fm URL
+ const artistEncoded = encodeURIComponent(csvRecord.artist);
+ const trackEncoded = encodeURIComponent(csvRecord.track);
+ playRecord.originUrl = `https://www.last.fm/music/${artistEncoded}/_/${trackEncoded}`;
+
+ return playRecord;
+}
+
+/**
+ * Format duration in human-readable format
+ */
+function formatDuration(milliseconds) {
+ const seconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+
+ if (hours > 0) {
+ const mins = minutes % 60;
+ return `${hours}h ${mins}m`;
+ } else if (minutes > 0) {
+ const secs = seconds % 60;
+ return `${minutes}m ${secs}s`;
+ } else {
+ return `${seconds}s`;
+ }
+}
+
+/**
+ * Publish records in batches with rate limiting and killswitch support
+ */
+async function publishRecords(agent, records, batchSize, batchDelay, dryRun = false) {
+ const totalRecords = records.length;
+ let successCount = 0;
+ let errorCount = 0;
+ const startTime = Date.now();
+
+ if (dryRun) {
+ console.log(`\n=== DRY RUN MODE ===`);
+ console.log(`Would publish ${totalRecords} records in batches of ${batchSize}`);
+ console.log(`Estimated time: ${formatDuration(Math.ceil(totalRecords / batchSize) * batchDelay)}\n`);
+
+ // Show first 5 records as preview
+ const previewCount = Math.min(5, totalRecords);
+ console.log(`Preview of first ${previewCount} records (in processing order):\n`);
+
+ for (let i = 0; i < previewCount; i++) {
+ const record = records[i];
+ console.log(`${i + 1}. ${record.artists[0]?.artistName} - ${record.trackName}`);
+ console.log(` Album: ${record.releaseName || 'N/A'}`);
+ console.log(` Played: ${record.playedTime}`);
+ console.log(` URL: ${record.originUrl}`);
+
+ // Show MusicBrainz IDs if available
+ const mbids = [];
+ if (record.artists[0]?.artistMbId) mbids.push(`Artist: ${record.artists[0].artistMbId}`);
+ if (record.recordingMbId) mbids.push(`Recording: ${record.recordingMbId}`);
+ if (record.releaseMbId) mbids.push(`Release: ${record.releaseMbId}`);
+
+ if (mbids.length > 0) {
+ console.log(` MBIDs: ${mbids.join(', ')}`);
+ }
+ console.log('');
+ }
+
+ if (totalRecords > previewCount) {
+ console.log(`... and ${totalRecords - previewCount} more records\n`);
+ }
+
+ console.log('=== DRY RUN COMPLETE ===');
+ console.log('No records were actually published.');
+ console.log('Remove --dry-run flag to publish for real.\n');
+
+ return { successCount: totalRecords, errorCount: 0, cancelled: false };
+ }
+
+ const totalBatches = Math.ceil(totalRecords / batchSize);
+ const estimatedTime = formatDuration(totalBatches * batchDelay);
+
+ console.log(`Publishing ${totalRecords} records in batches of ${batchSize}...`);
+ console.log(`Total batches: ${totalBatches}`);
+ console.log(`Estimated time: ${estimatedTime}`);
+ console.log(`\n🚨 Press Ctrl+C to stop gracefully after current batch\n`);
+
+ for (let i = 0; i < totalRecords; i += batchSize) {
+ // Check killswitch before processing batch
+ if (importCancelled) {
+ console.log(`\n🛑 Import cancelled by user`);
+ console.log(` Processed: ${successCount}/${totalRecords} records`);
+ console.log(` Remaining: ${totalRecords - successCount} records\n`);
+ return { successCount, errorCount, cancelled: true };
+ }
+
+ const batch = records.slice(i, i + batchSize);
+ const batchNum = Math.floor(i / batchSize) + 1;
+ const progress = ((i / totalRecords) * 100).toFixed(1);
+
+ console.log(`[${progress}%] Batch ${batchNum}/${totalBatches} (records ${i + 1}-${Math.min(i + batchSize, totalRecords)})`);
+
+ // Process batch records
+ const batchStartTime = Date.now();
+ for (const record of batch) {
+ // Check killswitch during batch processing
+ if (importCancelled) {
+ console.log(` ⚠️ Stopping mid-batch...`);
+ break;
+ }
+
+ try {
+ await agent.com.atproto.repo.createRecord({
+ repo: agent.session.did,
+ collection: RECORD_TYPE,
+ record,
+ });
+ successCount++;
+ } catch (error) {
+ errorCount++;
+ console.error(` ✗ Failed: ${record.trackName} - ${error.message}`);
+ }
+ }
+
+ const batchDuration = Date.now() - batchStartTime;
+ const elapsed = formatDuration(Date.now() - startTime);
+ const remaining = formatDuration(((totalRecords - i - batchSize) / batchSize) * batchDelay);
+
+ console.log(` ✓ Complete in ${batchDuration}ms (${successCount} successful, ${errorCount} failed)`);
+
+ // Only show time estimates if not cancelled
+ if (!importCancelled) {
+ console.log(` ⏱ Elapsed: ${elapsed} | Remaining: ~${remaining}\n`);
+ }
+
+ // Check again before waiting (in case cancelled during batch)
+ if (importCancelled) {
+ console.log(`\n🛑 Import cancelled by user`);
+ console.log(` Processed: ${successCount}/${totalRecords} records`);
+ console.log(` Remaining: ${totalRecords - successCount} records\n`);
+ return { successCount, errorCount, cancelled: true };
+ }
+
+ // Wait before next batch (except for last batch)
+ if (i + batchSize < totalRecords) {
+ await new Promise(resolve => setTimeout(resolve, batchDelay));
+ }
+ }
+
+ return { successCount, errorCount, cancelled: false };
+}
+
+/**
+ * Main execution
+ */
+async function main() {
+ const args = parseCommandLineArgs();
+
+ // Show help if requested
+ if (args.help) {
+ showHelp();
+ process.exit(0);
+ }
+
+ // Setup killswitch (unless in dry-run mode)
+ if (!args['dry-run']) {
+ setupKillswitch();
+ }
+
+ try {
+ console.log('=== Last.fm to ATProto Importer ===\n');
+
+ // Get CSV file path
+ let csvPath = args.file;
+ if (!csvPath) {
+ csvPath = await prompt('Enter path to Last.fm CSV export: ');
+ } else {
+ console.log(`CSV file: ${csvPath}`);
+ }
+
+ if (!fs.existsSync(csvPath)) {
+ console.error('✗ File not found!');
+ process.exit(1);
+ }
+
+ // Parse CSV
+ const csvRecords = parseLastFmCsv(csvPath);
+
+ if (csvRecords.length === 0) {
+ console.error('✗ No records found in CSV file!');
+ process.exit(1);
+ }
+
+ // Convert records
+ console.log('Converting records to ATProto format...');
+ const playRecords = csvRecords.map(convertToPlayRecord);
+ console.log('✓ Conversion complete\n');
+
+ // Sort records chronologically (oldest first) unless reverse flag is set
+ const reverseChronological = args['reverse-chronological'];
+ console.log(`Sorting records ${reverseChronological ? 'newest' : 'oldest'} first...`);
+
+ playRecords.sort((a, b) => {
+ const timeA = new Date(a.playedTime).getTime();
+ const timeB = new Date(b.playedTime).getTime();
+ return reverseChronological ? timeB - timeA : timeA - timeB;
+ });
+
+ const firstPlay = new Date(playRecords[0].playedTime).toLocaleDateString();
+ const lastPlay = new Date(playRecords[playRecords.length - 1].playedTime).toLocaleDateString();
+ console.log(`✓ Sorted ${playRecords.length} records`);
+ console.log(` First: ${firstPlay}`);
+ console.log(` Last: ${lastPlay}\n`);
+
+ // Validate and set batch delay with minimum enforcement first
+ let batchDelay = args['batch-delay'] ? parseInt(args['batch-delay']) : DEFAULT_BATCH_DELAY;
+ if (batchDelay < MIN_BATCH_DELAY) {
+ console.log(`⚠️ Batch delay ${batchDelay}ms is below minimum safe limit.`);
+ console.log(` Enforcing minimum delay of ${MIN_BATCH_DELAY}ms to respect rate limits.\n`);
+ batchDelay = MIN_BATCH_DELAY;
+ }
+
+ // Calculate optimal batch size if not specified (considers batch delay)
+ let batchSize = args['batch-size'] ? parseInt(args['batch-size']) : null;
+ if (!batchSize) {
+ batchSize = calculateOptimalBatchSize(playRecords.length, batchDelay);
+ console.log(`Auto-calculated batch size: ${batchSize}`);
+ console.log(` Algorithm: Logarithmic scaling with O(n) time complexity`);
+ console.log(` Optimized for: ${playRecords.length} records at ${batchDelay}ms delay`);
+ console.log(` Rate limit strategy: Token bucket with conservative limits\n`);
+ } else {
+ console.log(`Using specified batch size: ${batchSize}\n`);
+ }
+
+ // Check if dry run mode
+ const isDryRun = args['dry-run'];
+
+ if (isDryRun) {
+ console.log('🔍 Running in DRY RUN mode - no authentication required\n');
+
+ // Show preview without publishing
+ await publishRecords(null, playRecords, batchSize, batchDelay, true);
+ process.exit(0);
+ }
+
+ // Login to ATProto (only if not dry run)
+ const agent = await login(args.identifier, args.password);
+
+ // Confirm before publishing (unless --yes flag is set)
+ if (!args.yes) {
+ const confirm = await prompt(`\nReady to publish ${playRecords.length} records. Continue? (yes/no): `);
+ if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
+ console.log('Aborted.');
+ process.exit(0);
+ }
+ console.log('');
+ } else {
+ console.log(`Auto-confirmed: Publishing ${playRecords.length} records...\n`);
+ }
+
+ // Publish records
+ const startTime = Date.now();
+ const { successCount, errorCount, cancelled } = await publishRecords(agent, playRecords, batchSize, batchDelay, false);
+ const totalTime = formatDuration(Date.now() - startTime);
+
+ // Summary
+ console.log('=== Import Complete ===');
+ if (cancelled) {
+ console.log('Status: CANCELLED BY USER');
+ } else {
+ console.log('Status: COMPLETED');
+ }
+ console.log(`Total records: ${playRecords.length}`);
+ console.log(`Successfully published: ${successCount}`);
+ console.log(`Failed: ${errorCount}`);
+ if (cancelled) {
+ console.log(`Not processed: ${playRecords.length - successCount - errorCount}`);
+ }
+ console.log(`Total time: ${totalTime}`);
+
+ if (successCount > 0) {
+ const avgTime = (Date.now() - startTime) / successCount;
+ console.log(`Average time per record: ${avgTime.toFixed(0)}ms`);
+ }
+
+ console.log('\n✓ Logged out');
+
+ // Exit with appropriate code
+ process.exit(cancelled ? 130 : 0);
+
+ } catch (error) {
+ console.error('\n✗ Fatal error:', error.message);
+ if (error.stack && process.env.DEBUG) {
+ console.error('\nStack trace:', error.stack);
+ }
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/lexicons/fm.teal.alpha/actor/defs.json b/lexicons/fm.teal.alpha/actor/defs.json
new file mode 100644
index 0000000..1a2d912
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/defs.json
@@ -0,0 +1,84 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.defs",
+ "defs": {
+ "profileView": {
+ "type": "object",
+ "properties": {
+ "did": {
+ "type": "string",
+ "description": "The decentralized identifier of the actor"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "description": "Free-form profile description text."
+ },
+ "descriptionFacets": {
+ "type": "array",
+ "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.",
+ "items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
+ },
+ "featuredItem": {
+ "type": "ref",
+ "description": "The user's most recent item featured on their profile.",
+ "ref": "fm.teal.alpha.actor.profile#featuredItem"
+ },
+ "avatar": {
+ "type": "string",
+ "description": "IPLD of the avatar"
+ },
+ "banner": {
+ "type": "string",
+ "description": "IPLD of the banner image"
+ },
+ "status": {
+ "type": "ref",
+ "ref": "#statusView"
+ },
+ "createdAt": { "type": "string", "format": "datetime" }
+ }
+ },
+ "miniProfileView": {
+ "type": "object",
+ "properties": {
+ "did": {
+ "type": "string",
+ "description": "The decentralized identifier of the actor"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "handle": {
+ "type": "string"
+ },
+ "avatar": {
+ "type": "string",
+ "description": "IPLD of the avatar"
+ }
+ }
+ },
+ "statusView": {
+ "type": "object",
+ "description": "A declaration of the status of the actor.",
+ "properties": {
+ "time": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The unix timestamp of when the item was recorded"
+ },
+ "expiry": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time."
+ },
+ "item": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.feed.defs#playView"
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/actor/getProfile.json b/lexicons/fm.teal.alpha/actor/getProfile.json
new file mode 100644
index 0000000..6f2987d
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/getProfile.json
@@ -0,0 +1,34 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.getProfile",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["actor"],
+ "properties": {
+ "actor": {
+ "type": "string",
+ "format": "at-identifier",
+ "description": "The author's DID"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["actor"],
+ "properties": {
+ "actor": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.actor.defs#profileView"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/actor/getProfiles.json b/lexicons/fm.teal.alpha/actor/getProfiles.json
new file mode 100644
index 0000000..0d33064
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/getProfiles.json
@@ -0,0 +1,40 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.getProfiles",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves the associated profile.",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["actors"],
+ "properties": {
+ "actors": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "at-identifier"
+ },
+ "description": "Array of actor DIDs"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["actors"],
+ "properties": {
+ "actors": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.actor.defs#miniProfileView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/actor/profile.json b/lexicons/fm.teal.alpha/actor/profile.json
new file mode 100644
index 0000000..66fab85
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/profile.json
@@ -0,0 +1,64 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.profile",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm account profile.",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "properties": {
+ "displayName": {
+ "type": "string",
+ "maxGraphemes": 64,
+ "maxLength": 640
+ },
+ "description": {
+ "type": "string",
+ "description": "Free-form profile description text.",
+ "maxGraphemes": 256,
+ "maxLength": 2560
+ },
+ "descriptionFacets": {
+ "type": "array",
+ "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc).",
+ "items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
+ },
+ "featuredItem": {
+ "type": "ref",
+ "description": "The user's most recent item featured on their profile.",
+ "ref": "#featuredItem"
+ },
+ "avatar": {
+ "type": "blob",
+ "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'",
+ "accept": ["image/png", "image/jpeg"],
+ "maxSize": 1000000
+ },
+ "banner": {
+ "type": "blob",
+ "description": "Larger horizontal image to display behind profile view.",
+ "accept": ["image/png", "image/jpeg"],
+ "maxSize": 1000000
+ },
+ "createdAt": { "type": "string", "format": "datetime" }
+ }
+ }
+ },
+ "featuredItem": {
+ "type": "object",
+ "required": ["mbid", "type"],
+ "properties": {
+ "mbid": {
+ "type": "string",
+ "description": "The Musicbrainz ID of the item"
+ },
+ "type": {
+ "type": "string",
+ "description": "The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc."
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/actor/profileStatus.json b/lexicons/fm.teal.alpha/actor/profileStatus.json
new file mode 100644
index 0000000..da15287
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/profileStatus.json
@@ -0,0 +1,32 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.profileStatus",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the profile status of the actor.",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "required": ["completedOnboarding"],
+ "properties": {
+ "completedOnboarding": {
+ "type": "string",
+ "description": "The onboarding completion status",
+ "knownValues": ["none", "profileOnboarding", "playOnboarding", "complete"]
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The timestamp when this status was created"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The timestamp when this status was last updated"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/actor/searchActors.json b/lexicons/fm.teal.alpha/actor/searchActors.json
new file mode 100644
index 0000000..8f4b2fa
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/searchActors.json
@@ -0,0 +1,52 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.searchActors",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | Searches for actors based on profile contents.",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["q"],
+ "properties": {
+ "q": {
+ "type": "string",
+ "description": "The search query",
+ "maxGraphemes": 128,
+ "maxLength": 640
+ },
+ "limit": {
+ "type": "integer",
+ "description": "The maximum number of actors to return",
+ "minimum": 1,
+ "maximum": 25
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Cursor for pagination"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["actors"],
+ "properties": {
+ "actors": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.actor.defs#miniProfileView"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Cursor for pagination"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/actor/status.json b/lexicons/fm.teal.alpha/actor/status.json
new file mode 100644
index 0000000..33144d0
--- /dev/null
+++ b/lexicons/fm.teal.alpha/actor/status.json
@@ -0,0 +1,31 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.actor.status",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned.",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "required": ["time", "item"],
+ "properties": {
+ "time": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The unix timestamp of when the item was recorded"
+ },
+ "expiry": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time."
+ },
+ "item": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.feed.defs#playView"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/feed/defs.json b/lexicons/fm.teal.alpha/feed/defs.json
new file mode 100644
index 0000000..c4a90c4
--- /dev/null
+++ b/lexicons/fm.teal.alpha/feed/defs.json
@@ -0,0 +1,90 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.feed.defs",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to feeds.",
+ "defs": {
+ "playView": {
+ "type": "object",
+ "required": ["trackName", "artists"],
+ "properties": {
+ "trackName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "The name of the track"
+ },
+ "trackMbId": {
+ "type": "string",
+ "description": "The Musicbrainz ID of the track"
+ },
+ "recordingMbId": {
+ "type": "string",
+ "description": "The Musicbrainz recording ID of the track"
+ },
+ "duration": {
+ "type": "integer",
+ "description": "The length of the track in seconds"
+ },
+ "artists": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#artist"
+ },
+ "description": "Array of artists in order of original appearance."
+ },
+ "releaseName": {
+ "type": "string",
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "The name of the release/album"
+ },
+ "releaseMbId": {
+ "type": "string",
+ "description": "The Musicbrainz release ID"
+ },
+ "isrc": {
+ "type": "string",
+ "description": "The ISRC code associated with the recording"
+ },
+ "originUrl": {
+ "type": "string",
+ "description": "The URL associated with this track"
+ },
+ "musicServiceBaseDomain": {
+ "type": "string",
+ "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided."
+ },
+ "submissionClientAgent": {
+ "type": "string",
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided."
+ },
+ "playedTime": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The unix timestamp of when the track was played"
+ }
+ }
+ },
+ "artist": {
+ "type": "object",
+ "required": ["artistName"],
+ "properties": {
+ "artistName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "The name of the artist"
+ },
+ "artistMbId": {
+ "type": "string",
+ "description": "The Musicbrainz ID of the artist"
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/feed/getActorFeed.json b/lexicons/fm.teal.alpha/feed/getActorFeed.json
new file mode 100644
index 0000000..51b6c9d
--- /dev/null
+++ b/lexicons/fm.teal.alpha/feed/getActorFeed.json
@@ -0,0 +1,45 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.feed.getActorFeed",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves multiple plays from the index or via an author's DID.",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["authorDID"],
+ "properties": {
+ "authorDID": {
+ "type": "string",
+ "format": "at-identifier",
+ "description": "The author's DID for the play"
+ },
+ "cursor": {
+ "type": "string",
+ "description": "The cursor to start the query from"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "The upper limit of tracks to get per request. Default is 20, max is 50."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["plays"],
+ "properties": {
+ "plays": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.feed.defs#playView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/feed/getPlay.json b/lexicons/fm.teal.alpha/feed/getPlay.json
new file mode 100644
index 0000000..5ceba07
--- /dev/null
+++ b/lexicons/fm.teal.alpha/feed/getPlay.json
@@ -0,0 +1,38 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.feed.getPlay",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["authorDID", "rkey"],
+ "properties": {
+ "authorDID": {
+ "type": "string",
+ "format": "at-identifier",
+ "description": "The author's DID for the play"
+ },
+ "rkey": {
+ "type": "string",
+ "description": "The record key of the play"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["play"],
+ "properties": {
+ "play": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.feed.defs#playView"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/feed/play.json b/lexicons/fm.teal.alpha/feed/play.json
new file mode 100644
index 0000000..ac1e38f
--- /dev/null
+++ b/lexicons/fm.teal.alpha/feed/play.json
@@ -0,0 +1,107 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.feed.play",
+ "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm play. Plays are submitted as a result of a user listening to a track. Plays should be marked as tracked when a user has listened to the entire track if it's under 2 minutes long, or half of the track's duration up to 4 minutes, whichever is longest.",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["trackName"],
+ "properties": {
+ "trackName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "The name of the track"
+ },
+ "trackMbId": {
+ "type": "string",
+
+ "description": "The Musicbrainz ID of the track"
+ },
+ "recordingMbId": {
+ "type": "string",
+ "description": "The Musicbrainz recording ID of the track"
+ },
+ "duration": {
+ "type": "integer",
+ "description": "The length of the track in seconds"
+ },
+ "artistNames": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "maxGraphemes": 2560
+ },
+ "description": "Array of artist names in order of original appearance. Prefer using 'artists'."
+ },
+ "artistMbIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'."
+ },
+ "artists": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.feed.defs#artist"
+ },
+ "description": "Array of artists in order of original appearance."
+ },
+ "releaseName": {
+ "type": "string",
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "The name of the release/album"
+ },
+ "releaseMbId": {
+ "type": "string",
+ "description": "The Musicbrainz release ID"
+ },
+ "isrc": {
+ "type": "string",
+ "description": "The ISRC code associated with the recording"
+ },
+ "originUrl": {
+ "type": "string",
+ "description": "The URL associated with this track"
+ },
+ "musicServiceBaseDomain": {
+ "type": "string",
+ "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided."
+ },
+ "submissionClientAgent": {
+ "type": "string",
+ "maxLength": 256,
+ "maxGraphemes": 2560,
+ "description": "A metadata string specifying the user agent where the format is `/ (; ; )`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided."
+ },
+ "playedTime": {
+ "type": "string",
+ "format": "datetime",
+ "description": "The unix timestamp of when the track was played"
+ },
+ "trackDiscriminant": {
+ "type": "string",
+ "maxLength": 128,
+ "maxGraphemes": 1280,
+ "description": "Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities."
+ },
+ "releaseDiscriminant": {
+ "type": "string",
+ "maxLength": 128,
+ "maxGraphemes": 1280,
+ "description": "Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/stats/defs.json b/lexicons/fm.teal.alpha/stats/defs.json
new file mode 100644
index 0000000..8eb97db
--- /dev/null
+++ b/lexicons/fm.teal.alpha/stats/defs.json
@@ -0,0 +1,60 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.stats.defs",
+ "defs": {
+ "artistView": {
+ "type": "object",
+ "required": ["mbid", "name", "playCount"],
+ "properties": {
+ "mbid": {
+ "type": "string",
+ "description": "MusicBrainz artist ID"
+ },
+ "name": {
+ "type": "string",
+ "description": "Artist name"
+ },
+ "playCount": {
+ "type": "integer",
+ "description": "Total number of plays for this artist"
+ }
+ }
+ },
+ "releaseView": {
+ "type": "object",
+ "required": ["mbid", "name", "playCount"],
+ "properties": {
+ "mbid": {
+ "type": "string",
+ "description": "MusicBrainz release ID"
+ },
+ "name": {
+ "type": "string",
+ "description": "Release/album name"
+ },
+ "playCount": {
+ "type": "integer",
+ "description": "Total number of plays for this release"
+ }
+ }
+ },
+ "recordingView": {
+ "type": "object",
+ "required": ["mbid", "name", "playCount"],
+ "properties": {
+ "mbid": {
+ "type": "string",
+ "description": "MusicBrainz recording ID"
+ },
+ "name": {
+ "type": "string",
+ "description": "Recording/track name"
+ },
+ "playCount": {
+ "type": "integer",
+ "description": "Total number of plays for this recording"
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/fm.teal.alpha/stats/getLatest.json b/lexicons/fm.teal.alpha/stats/getLatest.json
new file mode 100644
index 0000000..aa9513c
--- /dev/null
+++ b/lexicons/fm.teal.alpha/stats/getLatest.json
@@ -0,0 +1,38 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.stats.getLatest",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get latest plays globally",
+ "parameters": {
+ "type": "params",
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Number of latest plays to return"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["plays"],
+ "properties": {
+ "plays": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.feed.defs#playView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lexicons/fm.teal.alpha/stats/getTopArtists.json b/lexicons/fm.teal.alpha/stats/getTopArtists.json
new file mode 100644
index 0000000..9d7dd85
--- /dev/null
+++ b/lexicons/fm.teal.alpha/stats/getTopArtists.json
@@ -0,0 +1,52 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.stats.getTopArtists",
+ "description": "Get top artists by play count",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "properties": {
+ "period": {
+ "type": "string",
+ "enum": ["all", "30days", "7days"],
+ "default": "all",
+ "description": "Time period for top artists"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Number of artists to return"
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Pagination cursor"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["artists"],
+ "properties": {
+ "artists": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.stats.defs#artistView"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Next page cursor"
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lexicons/fm.teal.alpha/stats/getTopReleases.json b/lexicons/fm.teal.alpha/stats/getTopReleases.json
new file mode 100644
index 0000000..349078b
--- /dev/null
+++ b/lexicons/fm.teal.alpha/stats/getTopReleases.json
@@ -0,0 +1,52 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.stats.getTopReleases",
+ "description": "Get top releases/albums by play count",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "properties": {
+ "period": {
+ "type": "string",
+ "enum": ["all", "30days", "7days"],
+ "default": "all",
+ "description": "Time period for top releases"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Number of releases to return"
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Pagination cursor"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["releases"],
+ "properties": {
+ "releases": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.stats.defs#releaseView"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Next page cursor"
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lexicons/fm.teal.alpha/stats/getUserTopArtists.json b/lexicons/fm.teal.alpha/stats/getUserTopArtists.json
new file mode 100644
index 0000000..ab2092e
--- /dev/null
+++ b/lexicons/fm.teal.alpha/stats/getUserTopArtists.json
@@ -0,0 +1,58 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.stats.getUserTopArtists",
+ "description": "Get a user's top artists by play count",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["actor"],
+ "properties": {
+ "actor": {
+ "type": "string",
+ "format": "at-identifier",
+ "description": "The user's DID or handle"
+ },
+ "period": {
+ "type": "string",
+ "enum": ["30days", "7days"],
+ "default": "30days",
+ "description": "Time period for top artists"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Number of artists to return"
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Pagination cursor"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["artists"],
+ "properties": {
+ "artists": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.stats.defs#artistView"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Next page cursor"
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lexicons/fm.teal.alpha/stats/getUserTopReleases.json b/lexicons/fm.teal.alpha/stats/getUserTopReleases.json
new file mode 100644
index 0000000..1534ed2
--- /dev/null
+++ b/lexicons/fm.teal.alpha/stats/getUserTopReleases.json
@@ -0,0 +1,58 @@
+{
+ "lexicon": 1,
+ "id": "fm.teal.alpha.stats.getUserTopReleases",
+ "description": "Get a user's top releases/albums by play count",
+ "defs": {
+ "main": {
+ "type": "query",
+ "parameters": {
+ "type": "params",
+ "required": ["actor"],
+ "properties": {
+ "actor": {
+ "type": "string",
+ "format": "at-identifier",
+ "description": "The user's DID or handle"
+ },
+ "period": {
+ "type": "string",
+ "enum": ["30days", "7days"],
+ "default": "30days",
+ "description": "Time period for top releases"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Number of releases to return"
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Pagination cursor"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["releases"],
+ "properties": {
+ "releases": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "fm.teal.alpha.stats.defs#releaseView"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Next page cursor"
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..acb3c57
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,137 @@
+{
+ "name": "lastfm-importer",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lastfm-importer",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/api": "^0.13.0",
+ "csv-parse": "^5.5.0"
+ }
+ },
+ "node_modules/@atproto/api": {
+ "version": "0.13.35",
+ "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.35.tgz",
+ "integrity": "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.0",
+ "@atproto/lexicon": "^0.4.6",
+ "@atproto/syntax": "^0.3.2",
+ "@atproto/xrpc": "^0.6.8",
+ "await-lock": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "tlds": "^1.234.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/common-web": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz",
+ "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==",
+ "license": "MIT",
+ "dependencies": {
+ "graphemer": "^1.4.0",
+ "multiformats": "^9.9.0",
+ "uint8arrays": "3.0.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/lexicon": {
+ "version": "0.4.14",
+ "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz",
+ "integrity": "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.2",
+ "@atproto/syntax": "^0.4.0",
+ "iso-datestring-validator": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
+ "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==",
+ "license": "MIT"
+ },
+ "node_modules/@atproto/syntax": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz",
+ "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==",
+ "license": "MIT"
+ },
+ "node_modules/@atproto/xrpc": {
+ "version": "0.6.12",
+ "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.12.tgz",
+ "integrity": "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lexicon": "^0.4.10",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/await-lock": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
+ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
+ "license": "MIT"
+ },
+ "node_modules/csv-parse": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
+ "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
+ "license": "MIT"
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "license": "MIT"
+ },
+ "node_modules/iso-datestring-validator": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
+ "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==",
+ "license": "MIT"
+ },
+ "node_modules/multiformats": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
+ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
+ "license": "(Apache-2.0 AND MIT)"
+ },
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
+ "node_modules/uint8arrays": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
+ "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.4.2"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2949aa1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "lastfm-importer",
+ "version": "1.0.0",
+ "description": "Import Last.fm scrobbles to ATProto",
+ "type": "module",
+ "main": "importer.js",
+ "scripts": {
+ "start": "node importer.js",
+ "dry-run": "node importer.js --dry-run"
+ },
+ "keywords": [
+ "lastfm",
+ "atproto",
+ "bluesky",
+ "import"
+ ],
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/api": "^0.13.0",
+ "csv-parse": "^5.5.0"
+ }
+}
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..2c6a6f3
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,16 @@
+/**
+ * Configuration constants for the Last.fm importer
+ */
+
+export const DEFAULT_BATCH_SIZE = 10;
+export const DEFAULT_BATCH_DELAY = 1500;
+export const MIN_BATCH_DELAY = 100;
+export const RECORD_TYPE = 'fm.teal.alpha.feed.play';
+export const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc';
+export const CLIENT_AGENT = 'lastfm-importer/v0.0.1';
+
+// Batch size calculation constants
+export const MIN_RECORDS_FOR_SCALING = 100;
+export const BASE_BATCH_SIZE = 5;
+export const MAX_BATCH_SIZE = 50;
+export const SCALING_FACTOR = 1.5;
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..1988ea6
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+
+import * as fs from 'fs';
+import * as config from './config.js';
+import { parseCommandLineArgs, showHelp } from './lib/cli.js';
+import { login } from './lib/auth.js';
+import { parseLastFmCsv, convertToPlayRecord, sortRecords } from './lib/csv.js';
+import { publishRecords } from './lib/publisher.js';
+import { prompt } from './utils/input.js';
+import { formatDuration, calculateOptimalBatchSize } from './utils/helpers.js';
+import { setupKillswitch } from './utils/killswitch.js';
+
+/**
+ * Main execution
+ */
+async function main() {
+ const args = parseCommandLineArgs();
+
+ // Show help if requested
+ if (args.help) {
+ showHelp();
+ process.exit(0);
+ }
+
+ // Setup killswitch (unless in dry-run mode)
+ if (!args['dry-run']) {
+ setupKillswitch();
+ }
+
+ try {
+ console.log('=== Last.fm to ATProto Importer ===\n');
+
+ // Get CSV file path
+ let csvPath = args.file;
+ if (!csvPath) {
+ csvPath = await prompt('Enter path to Last.fm CSV export: ');
+ } else {
+ console.log(`CSV file: ${csvPath}`);
+ }
+
+ if (!fs.existsSync(csvPath)) {
+ console.error('✗ File not found!');
+ process.exit(1);
+ }
+
+ // Parse CSV
+ const csvRecords = parseLastFmCsv(csvPath);
+
+ if (csvRecords.length === 0) {
+ console.error('✗ No records found in CSV file!');
+ process.exit(1);
+ }
+
+ // Convert records
+ console.log('Converting records to ATProto format...');
+ const playRecords = csvRecords.map(record => convertToPlayRecord(record, config));
+ console.log('✓ Conversion complete\n');
+
+ // Sort records chronologically
+ const reverseChronological = args['reverse-chronological'];
+ sortRecords(playRecords, reverseChronological);
+
+ // Validate and set batch delay
+ let batchDelay = args['batch-delay'] ? parseInt(args['batch-delay']) : config.DEFAULT_BATCH_DELAY;
+ if (batchDelay < config.MIN_BATCH_DELAY) {
+ console.log(`⚠️ Batch delay ${batchDelay}ms is below minimum safe limit.`);
+ console.log(` Enforcing minimum delay of ${config.MIN_BATCH_DELAY}ms to respect rate limits.\n`);
+ batchDelay = config.MIN_BATCH_DELAY;
+ }
+
+ // Calculate optimal batch size
+ let batchSize = args['batch-size'] ? parseInt(args['batch-size']) : null;
+ if (!batchSize) {
+ batchSize = calculateOptimalBatchSize(playRecords.length, batchDelay, config);
+ console.log(`Auto-calculated batch size: ${batchSize}`);
+ console.log(` Algorithm: Logarithmic scaling with O(n) time complexity`);
+ console.log(` Optimized for: ${playRecords.length} records at ${batchDelay}ms delay`);
+ console.log(` Rate limit strategy: Token bucket with conservative limits\n`);
+ } else {
+ console.log(`Using specified batch size: ${batchSize}\n`);
+ }
+
+ // Check if dry run mode
+ const isDryRun = args['dry-run'];
+
+ if (isDryRun) {
+ console.log('🔍 Running in DRY RUN mode - no authentication required\n');
+
+ // Show preview without publishing
+ await publishRecords(null, playRecords, batchSize, batchDelay, config, true);
+ process.exit(0);
+ }
+
+ // Login to ATProto (only if not dry run)
+ const agent = await login(args.identifier, args.password, config.SLINGSHOT_RESOLVER);
+
+ // Confirm before publishing (unless --yes flag is set)
+ if (!args.yes) {
+ const confirm = await prompt(`\nReady to publish ${playRecords.length} records. Continue? (yes/no): `);
+ if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
+ console.log('Aborted.');
+ process.exit(0);
+ }
+ console.log('');
+ } else {
+ console.log(`Auto-confirmed: Publishing ${playRecords.length} records...\n`);
+ }
+
+ // Publish records
+ const startTime = Date.now();
+ const { successCount, errorCount, cancelled } = await publishRecords(
+ agent,
+ playRecords,
+ batchSize,
+ batchDelay,
+ config,
+ false
+ );
+ const totalTime = formatDuration(Date.now() - startTime);
+
+ // Summary
+ console.log('=== Import Complete ===');
+ if (cancelled) {
+ console.log('Status: CANCELLED BY USER');
+ } else {
+ console.log('Status: COMPLETED');
+ }
+ console.log(`Total records: ${playRecords.length}`);
+ console.log(`Successfully published: ${successCount}`);
+ console.log(`Failed: ${errorCount}`);
+ if (cancelled) {
+ console.log(`Not processed: ${playRecords.length - successCount - errorCount}`);
+ }
+ console.log(`Total time: ${totalTime}`);
+
+ if (successCount > 0) {
+ const avgTime = (Date.now() - startTime) / successCount;
+ console.log(`Average time per record: ${avgTime.toFixed(0)}ms`);
+ }
+
+ console.log('\n✓ Logged out');
+
+ // Exit with appropriate code
+ process.exit(cancelled ? 130 : 0);
+
+ } catch (error) {
+ console.error('\n✗ Fatal error:', error.message);
+ if (error.stack && process.env.DEBUG) {
+ console.error('\nStack trace:', error.stack);
+ }
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/src/lib/auth.js b/src/lib/auth.js
new file mode 100644
index 0000000..c7ea488
--- /dev/null
+++ b/src/lib/auth.js
@@ -0,0 +1,79 @@
+import { AtpAgent } from '@atproto/api';
+import { prompt } from '../utils/input.js';
+
+/**
+ * Resolves an AT Protocol identifier (handle or DID) to get PDS information
+ */
+async function resolveIdentifier(identifier, resolverUrl) {
+ console.log(`Resolving identifier: ${identifier}`);
+
+ const response = await fetch(
+ `${resolverUrl}?identifier=${encodeURIComponent(identifier)}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ if (!data.did || !data.pds) {
+ throw new Error('Invalid response from identity resolver');
+ }
+
+ console.log(`✓ Resolved to PDS: ${data.pds}`);
+ return data;
+}
+
+/**
+ * Login to ATProto using Slingshot resolver
+ */
+export async function login(identifier, password, resolverUrl) {
+ console.log('\n=== ATProto Login ===');
+
+ // Prompt for missing credentials
+ if (!identifier) {
+ identifier = await prompt('Handle or DID: ');
+ } else {
+ console.log(`Handle or DID: ${identifier}`);
+ }
+
+ if (!password) {
+ password = await prompt('App password: ', true);
+ } else {
+ console.log('App password: [hidden]');
+ }
+
+ try {
+ // Resolve the identifier to get PDS
+ const resolved = await resolveIdentifier(identifier, resolverUrl);
+
+ // Create agent with resolved PDS
+ const pdsAgent = new AtpAgent({ service: resolved.pds });
+
+ // Login using the resolved DID
+ await pdsAgent.login({
+ identifier: resolved.did,
+ password: password,
+ });
+
+ console.log('✓ Logged in successfully!');
+ console.log(` DID: ${pdsAgent.session.did}`);
+ console.log(` Handle: ${pdsAgent.session.handle}\n`);
+
+ return pdsAgent;
+ } catch (error) {
+ console.error('✗ Login failed:', error.message);
+
+ // Provide more specific error messages
+ if (error.message.includes('Failed to resolve identifier')) {
+ throw new Error('Handle not found. Please check your AT Protocol handle.');
+ } else if (error.message.includes('AuthFactorTokenRequired')) {
+ throw new Error('Two-factor authentication required. Please use your app password.');
+ } else if (error.message.includes('InvalidCredentials')) {
+ throw new Error('Invalid credentials. Please check your handle and app password.');
+ }
+
+ throw error;
+ }
+}
diff --git a/src/lib/cli.js b/src/lib/cli.js
new file mode 100644
index 0000000..a95bd89
--- /dev/null
+++ b/src/lib/cli.js
@@ -0,0 +1,93 @@
+import { parseArgs } from 'node:util';
+
+/**
+ * Parse command line arguments
+ */
+export function parseCommandLineArgs() {
+ const options = {
+ help: {
+ type: 'boolean',
+ short: 'h',
+ default: false,
+ },
+ file: {
+ type: 'string',
+ short: 'f',
+ },
+ identifier: {
+ type: 'string',
+ short: 'i',
+ },
+ password: {
+ type: 'string',
+ short: 'p',
+ },
+ 'batch-size': {
+ type: 'string',
+ short: 'b',
+ },
+ 'batch-delay': {
+ type: 'string',
+ short: 'd',
+ },
+ yes: {
+ type: 'boolean',
+ short: 'y',
+ default: false,
+ },
+ 'dry-run': {
+ type: 'boolean',
+ short: 'n',
+ default: false,
+ },
+ 'reverse-chronological': {
+ type: 'boolean',
+ short: 'r',
+ default: false,
+ },
+ };
+
+ try {
+ const { values } = parseArgs({ options, allowPositionals: false });
+ return values;
+ } catch (error) {
+ console.error('Error parsing arguments:', error.message);
+ showHelp();
+ process.exit(1);
+ }
+}
+
+/**
+ * Show help message
+ */
+export function showHelp() {
+ console.log(`
+Last.fm to ATProto Importer
+
+Usage: node importer.js [options]
+
+Options:
+ -h, --help Show this help message
+ -f, --file Path to Last.fm CSV export file
+ -i, --identifier ATProto handle or DID
+ -p, --password ATProto app password
+ -b, --batch-size Number of records per batch (auto-calculated if not set)
+ -d, --batch-delay Delay between batches in ms (default: 2000, min: 1000)
+ -y, --yes Skip confirmation prompt
+ -n, --dry-run Preview records without publishing
+ -r, --reverse-chronological Process newest first (default: oldest first)
+
+Examples:
+ node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx
+ node importer.js --file export.csv --identifier alice.bsky.social --yes
+ node importer.js -f lastfm.csv --dry-run
+ node importer.js (interactive mode - prompts for all values)
+
+Notes:
+ - Batch size uses logarithmic scaling algorithm (O(n) complexity) for optimal throughput
+ - Auto-calculated batch size considers both record count and delay settings
+ - Records are processed in chronological order (oldest first) by default
+ - Minimum batch delay of 1000ms enforced to respect rate limits
+ - Rate limiting follows token bucket strategy for safe API usage
+`);
+}
diff --git a/src/lib/csv.js b/src/lib/csv.js
new file mode 100644
index 0000000..f38cdab
--- /dev/null
+++ b/src/lib/csv.js
@@ -0,0 +1,93 @@
+import * as fs from 'fs';
+import { parse } from 'csv-parse/sync';
+
+/**
+ * Parse Last.fm CSV export
+ */
+export function parseLastFmCsv(filePath) {
+ console.log(`Reading CSV file: ${filePath}`);
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
+
+ const records = parse(fileContent, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+
+ console.log(`✓ Parsed ${records.length} scrobbles\n`);
+ return records;
+}
+
+/**
+ * Convert Last.fm CSV record to ATProto play record
+ */
+export function convertToPlayRecord(csvRecord, config) {
+ const { RECORD_TYPE, CLIENT_AGENT } = config;
+
+ // Parse the timestamp
+ const timestamp = parseInt(csvRecord.uts);
+ const playedTime = new Date(timestamp * 1000).toISOString();
+
+ // Build artists array
+ const artists = [];
+ if (csvRecord.artist) {
+ const artistData = {
+ artistName: csvRecord.artist,
+ };
+ if (csvRecord.artist_mbid && csvRecord.artist_mbid.trim()) {
+ artistData.artistMbId = csvRecord.artist_mbid;
+ }
+ artists.push(artistData);
+ }
+
+ // Build the play record
+ const playRecord = {
+ $type: RECORD_TYPE,
+ trackName: csvRecord.track,
+ artists,
+ playedTime,
+ submissionClientAgent: CLIENT_AGENT,
+ musicServiceBaseDomain: 'last.fm',
+ };
+
+ // Add optional fields
+ if (csvRecord.album && csvRecord.album.trim()) {
+ playRecord.releaseName = csvRecord.album;
+ }
+
+ if (csvRecord.album_mbid && csvRecord.album_mbid.trim()) {
+ playRecord.releaseMbId = csvRecord.album_mbid;
+ }
+
+ if (csvRecord.track_mbid && csvRecord.track_mbid.trim()) {
+ playRecord.recordingMbId = csvRecord.track_mbid;
+ }
+
+ // Generate Last.fm URL
+ const artistEncoded = encodeURIComponent(csvRecord.artist);
+ const trackEncoded = encodeURIComponent(csvRecord.track);
+ playRecord.originUrl = `https://www.last.fm/music/${artistEncoded}/_/${trackEncoded}`;
+
+ return playRecord;
+}
+
+/**
+ * Sort records chronologically
+ */
+export function sortRecords(records, reverseChronological = false) {
+ console.log(`Sorting records ${reverseChronological ? 'newest' : 'oldest'} first...`);
+
+ records.sort((a, b) => {
+ const timeA = new Date(a.playedTime).getTime();
+ const timeB = new Date(b.playedTime).getTime();
+ return reverseChronological ? timeB - timeA : timeA - timeB;
+ });
+
+ const firstPlay = new Date(records[0].playedTime).toLocaleDateString();
+ const lastPlay = new Date(records[records.length - 1].playedTime).toLocaleDateString();
+ console.log(`✓ Sorted ${records.length} records`);
+ console.log(` First: ${firstPlay}`);
+ console.log(` Last: ${lastPlay}\n`);
+
+ return records;
+}
diff --git a/src/lib/publisher.js b/src/lib/publisher.js
new file mode 100644
index 0000000..0ec4b77
--- /dev/null
+++ b/src/lib/publisher.js
@@ -0,0 +1,137 @@
+import { formatDuration } from '../utils/helpers.js';
+import { isImportCancelled } from '../utils/killswitch.js';
+
+/**
+ * Publish records in batches with rate limiting and killswitch support
+ */
+export async function publishRecords(agent, records, batchSize, batchDelay, config, dryRun = false) {
+ const { RECORD_TYPE } = config;
+ const totalRecords = records.length;
+ let successCount = 0;
+ let errorCount = 0;
+ const startTime = Date.now();
+
+ if (dryRun) {
+ return handleDryRun(records, batchSize, batchDelay);
+ }
+
+ const totalBatches = Math.ceil(totalRecords / batchSize);
+ const estimatedTime = formatDuration(totalBatches * batchDelay);
+
+ console.log(`Publishing ${totalRecords} records in batches of ${batchSize}...`);
+ console.log(`Total batches: ${totalBatches}`);
+ console.log(`Estimated time: ${estimatedTime}`);
+ console.log(`\n🚨 Press Ctrl+C to stop gracefully after current batch\n`);
+
+ for (let i = 0; i < totalRecords; i += batchSize) {
+ // Check killswitch before processing batch
+ if (isImportCancelled()) {
+ return handleCancellation(successCount, errorCount, totalRecords);
+ }
+
+ const batch = records.slice(i, i + batchSize);
+ const batchNum = Math.floor(i / batchSize) + 1;
+ const progress = ((i / totalRecords) * 100).toFixed(1);
+
+ console.log(`[${progress}%] Batch ${batchNum}/${totalBatches} (records ${i + 1}-${Math.min(i + batchSize, totalRecords)})`);
+
+ // Process batch records
+ const batchStartTime = Date.now();
+ for (const record of batch) {
+ // Check killswitch during batch processing
+ if (isImportCancelled()) {
+ console.log(` ⚠️ Stopping mid-batch...`);
+ break;
+ }
+
+ try {
+ await agent.com.atproto.repo.createRecord({
+ repo: agent.session.did,
+ collection: RECORD_TYPE,
+ record,
+ });
+ successCount++;
+ } catch (error) {
+ errorCount++;
+ console.error(` ✗ Failed: ${record.trackName} - ${error.message}`);
+ }
+ }
+
+ const batchDuration = Date.now() - batchStartTime;
+ const elapsed = formatDuration(Date.now() - startTime);
+ const remaining = formatDuration(((totalRecords - i - batchSize) / batchSize) * batchDelay);
+
+ console.log(` ✓ Complete in ${batchDuration}ms (${successCount} successful, ${errorCount} failed)`);
+
+ // Only show time estimates if not cancelled
+ if (!isImportCancelled()) {
+ console.log(` ⏱ Elapsed: ${elapsed} | Remaining: ~${remaining}\n`);
+ }
+
+ // Check again before waiting
+ if (isImportCancelled()) {
+ return handleCancellation(successCount, errorCount, totalRecords);
+ }
+
+ // Wait before next batch (except for last batch)
+ if (i + batchSize < totalRecords) {
+ await new Promise(resolve => setTimeout(resolve, batchDelay));
+ }
+ }
+
+ return { successCount, errorCount, cancelled: false };
+}
+
+/**
+ * Handle dry run mode
+ */
+function handleDryRun(records, batchSize, batchDelay) {
+ const totalRecords = records.length;
+
+ console.log(`\n=== DRY RUN MODE ===`);
+ console.log(`Would publish ${totalRecords} records in batches of ${batchSize}`);
+ console.log(`Estimated time: ${formatDuration(Math.ceil(totalRecords / batchSize) * batchDelay)}\n`);
+
+ // Show first 5 records as preview
+ const previewCount = Math.min(5, totalRecords);
+ console.log(`Preview of first ${previewCount} records (in processing order):\n`);
+
+ for (let i = 0; i < previewCount; i++) {
+ const record = records[i];
+ console.log(`${i + 1}. ${record.artists[0]?.artistName} - ${record.trackName}`);
+ console.log(` Album: ${record.releaseName || 'N/A'}`);
+ console.log(` Played: ${record.playedTime}`);
+ console.log(` URL: ${record.originUrl}`);
+
+ // Show MusicBrainz IDs if available
+ const mbids = [];
+ if (record.artists[0]?.artistMbId) mbids.push(`Artist: ${record.artists[0].artistMbId}`);
+ if (record.recordingMbId) mbids.push(`Recording: ${record.recordingMbId}`);
+ if (record.releaseMbId) mbids.push(`Release: ${record.releaseMbId}`);
+
+ if (mbids.length > 0) {
+ console.log(` MBIDs: ${mbids.join(', ')}`);
+ }
+ console.log('');
+ }
+
+ if (totalRecords > previewCount) {
+ console.log(`... and ${totalRecords - previewCount} more records\n`);
+ }
+
+ console.log('=== DRY RUN COMPLETE ===');
+ console.log('No records were actually published.');
+ console.log('Remove --dry-run flag to publish for real.\n');
+
+ return { successCount: totalRecords, errorCount: 0, cancelled: false };
+}
+
+/**
+ * Handle cancellation
+ */
+function handleCancellation(successCount, errorCount, totalRecords) {
+ console.log(`\n🛑 Import cancelled by user`);
+ console.log(` Processed: ${successCount}/${totalRecords} records`);
+ console.log(` Remaining: ${totalRecords - successCount} records\n`);
+ return { successCount, errorCount, cancelled: true };
+}
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
new file mode 100644
index 0000000..d943ab1
--- /dev/null
+++ b/src/utils/helpers.js
@@ -0,0 +1,63 @@
+/**
+ * Utility functions for the Last.fm importer
+ */
+
+/**
+ * Format duration in human-readable format
+ */
+export function formatDuration(milliseconds) {
+ const seconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+
+ if (hours > 0) {
+ const mins = minutes % 60;
+ return `${hours}h ${mins}m`;
+ } else if (minutes > 0) {
+ const secs = seconds % 60;
+ return `${minutes}m ${secs}s`;
+ } else {
+ return `${seconds}s`;
+ }
+}
+
+/**
+ * Calculate optimal batch size based on total records and rate limits
+ * Uses a logarithmic scaling approach to balance throughput with API safety
+ */
+export function calculateOptimalBatchSize(totalRecords, batchDelay, config) {
+ const {
+ MIN_RECORDS_FOR_SCALING,
+ BASE_BATCH_SIZE,
+ MAX_BATCH_SIZE,
+ SCALING_FACTOR,
+ DEFAULT_BATCH_DELAY
+ } = config;
+
+ const delay = batchDelay || DEFAULT_BATCH_DELAY;
+
+ // For very small datasets, use minimal batches
+ if (totalRecords <= 50) {
+ return 3;
+ }
+
+ // For small to medium datasets, use conservative batching
+ if (totalRecords <= MIN_RECORDS_FOR_SCALING) {
+ return BASE_BATCH_SIZE;
+ }
+
+ // Logarithmic scaling
+ const logScale = Math.log2(totalRecords / MIN_RECORDS_FOR_SCALING);
+ const calculatedSize = Math.floor(BASE_BATCH_SIZE + (logScale * SCALING_FACTOR));
+
+ // Apply maximum cap
+ let optimalSize = Math.min(calculatedSize, MAX_BATCH_SIZE);
+
+ // Adjust based on batch delay
+ if (delay < 1500 && optimalSize > 15) {
+ optimalSize = Math.floor(optimalSize * 0.75);
+ }
+
+ // Ensure batch size is at least 3
+ return Math.max(3, optimalSize);
+}
diff --git a/src/utils/input.js b/src/utils/input.js
new file mode 100644
index 0000000..1be8961
--- /dev/null
+++ b/src/utils/input.js
@@ -0,0 +1,71 @@
+import * as readline from 'readline';
+
+/**
+ * Read user input from command line with proper password masking
+ */
+export function prompt(question, hideInput = false) {
+ return new Promise((resolve) => {
+ if (hideInput) {
+ // For password input, use raw mode
+ const stdin = process.stdin;
+ const wasRaw = stdin.isRaw;
+
+ // Set raw mode to capture individual keystrokes
+ if (stdin.isTTY) {
+ stdin.setRawMode(true);
+ }
+
+ stdin.resume();
+ stdin.setEncoding('utf8');
+
+ process.stdout.write(question);
+
+ let password = '';
+ const onData = (char) => {
+ char = char.toString();
+
+ switch (char) {
+ case '\n':
+ case '\r':
+ case '\u0004': // Ctrl-D
+ stdin.removeListener('data', onData);
+ if (stdin.isTTY) {
+ stdin.setRawMode(wasRaw);
+ }
+ stdin.pause();
+ process.stdout.write('\n');
+ resolve(password);
+ break;
+ case '\u0003': // Ctrl-C
+ process.exit(1);
+ break;
+ case '\u007f': // Backspace
+ case '\b': // Backspace
+ if (password.length > 0) {
+ password = password.slice(0, -1);
+ process.stdout.clearLine(0);
+ process.stdout.cursorTo(0);
+ process.stdout.write(question + '*'.repeat(password.length));
+ }
+ break;
+ default:
+ password += char;
+ process.stdout.write('*');
+ break;
+ }
+ };
+
+ stdin.on('data', onData);
+ } else {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer);
+ });
+ }
+ });
+}
diff --git a/src/utils/killswitch.js b/src/utils/killswitch.js
new file mode 100644
index 0000000..3b6400d
--- /dev/null
+++ b/src/utils/killswitch.js
@@ -0,0 +1,35 @@
+// Global state for killswitch
+let importCancelled = false;
+let gracefulShutdown = false;
+
+/**
+ * Setup killswitch handler for graceful shutdown
+ */
+export function setupKillswitch() {
+ process.on('SIGINT', () => {
+ if (gracefulShutdown) {
+ console.log('\n\n⚠️ Force quit detected. Exiting immediately...');
+ process.exit(1);
+ }
+
+ gracefulShutdown = true;
+ importCancelled = true;
+ console.log('\n\n🛑 Killswitch activated! Stopping after current batch...');
+ console.log(' Press Ctrl+C again to force quit immediately.\n');
+ });
+}
+
+/**
+ * Check if import has been cancelled
+ */
+export function isImportCancelled() {
+ return importCancelled;
+}
+
+/**
+ * Reset killswitch state (useful for testing)
+ */
+export function resetKillswitch() {
+ importCancelled = false;
+ gracefulShutdown = false;
+}